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,565 @@
|
|
|
1
|
+
package com.spotnysdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.RemoteException
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import com.facebook.react.bridge.*
|
|
7
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
8
|
+
import org.altbeacon.beacon.*
|
|
9
|
+
import java.io.File
|
|
10
|
+
import java.net.HttpURLConnection
|
|
11
|
+
import java.net.URL
|
|
12
|
+
import java.text.SimpleDateFormat
|
|
13
|
+
import java.util.*
|
|
14
|
+
import java.util.concurrent.Executors
|
|
15
|
+
import kotlin.math.abs
|
|
16
|
+
|
|
17
|
+
// ── Kontakt.io iBeacon layout ─────────────────────────────────────────────────
|
|
18
|
+
private const val IBEACON_LAYOUT = "m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24"
|
|
19
|
+
private const val BEACON_UUID = "f7826da6-4fa2-4e98-8024-bc5b71e0893e"
|
|
20
|
+
private const val TAG = "SpotnySDK"
|
|
21
|
+
|
|
22
|
+
// ── Campaign data ─────────────────────────────────────────────────────────────
|
|
23
|
+
private data class CampaignData(
|
|
24
|
+
val campaignId: Int?,
|
|
25
|
+
val screenId: Int,
|
|
26
|
+
val sessionId: String?,
|
|
27
|
+
val inQueue: Boolean,
|
|
28
|
+
val major: Int,
|
|
29
|
+
val minor: Int
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
|
|
33
|
+
NativeSpotnySdkSpec(reactContext) {
|
|
34
|
+
|
|
35
|
+
// ── Managers ──────────────────────────────────────────────────────────────
|
|
36
|
+
private val beaconManager: BeaconManager = BeaconManager.getInstanceForApplication(reactContext)
|
|
37
|
+
private val ioExecutor = Executors.newCachedThreadPool()
|
|
38
|
+
|
|
39
|
+
// ── Session state ─────────────────────────────────────────────────────────
|
|
40
|
+
private var currentUserUUID: String? = null
|
|
41
|
+
private var userId: Int? = null
|
|
42
|
+
@Volatile private var scanning = false
|
|
43
|
+
|
|
44
|
+
// ── Configuration ─────────────────────────────────────────────────────────
|
|
45
|
+
private var backendURL = "https://api.spotny.app"
|
|
46
|
+
private var maxDetectionDistance = 8.0 // metres
|
|
47
|
+
|
|
48
|
+
// ── Timing constants ──────────────────────────────────────────────────────
|
|
49
|
+
private var campaignFetchCooldown = 5_000L // ms
|
|
50
|
+
private var proximityDistanceThreshold = 0.75
|
|
51
|
+
private var impressionEventInterval = 10_000L // ms
|
|
52
|
+
private val impressionDistance = 2.0
|
|
53
|
+
private var debounceInterval = 5_000L // ms
|
|
54
|
+
|
|
55
|
+
// ── Per-beacon state ──────────────────────────────────────────────────────
|
|
56
|
+
private val activeCampaigns = mutableMapOf<String, CampaignData>()
|
|
57
|
+
private val lastProximityEventSent = mutableMapOf<String, Long>()
|
|
58
|
+
private val lastProximityDistance = mutableMapOf<String, Double>()
|
|
59
|
+
private val lastImpressionEventSent = mutableMapOf<String, Long>()
|
|
60
|
+
private val lastCampaignFetchAttempt = mutableMapOf<String, Long>()
|
|
61
|
+
private val fetchInProgress = mutableMapOf<String, Boolean>()
|
|
62
|
+
private val proximityEventInProgress = mutableMapOf<String, Boolean>()
|
|
63
|
+
private val impressionEventInProgress = mutableMapOf<String, Boolean>()
|
|
64
|
+
|
|
65
|
+
// ── Module registration ───────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
override fun getName() = NAME
|
|
68
|
+
|
|
69
|
+
override fun initialize() {
|
|
70
|
+
super.initialize()
|
|
71
|
+
setupBeaconManager()
|
|
72
|
+
resumeStoredSession()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Setup ─────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
private fun setupBeaconManager() {
|
|
78
|
+
BeaconManager.setDebug(false)
|
|
79
|
+
beaconManager.beaconParsers.add(BeaconParser().setBeaconLayout(IBEACON_LAYOUT))
|
|
80
|
+
beaconManager.foregroundScanPeriod = 1_100L
|
|
81
|
+
beaconManager.foregroundBetweenScanPeriod = 0L
|
|
82
|
+
beaconManager.backgroundScanPeriod = 10_000L
|
|
83
|
+
beaconManager.backgroundBetweenScanPeriod = 5_000L
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun resumeStoredSession() {
|
|
87
|
+
val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE)
|
|
88
|
+
val stored = prefs.getString("userUUID", null) ?: return
|
|
89
|
+
currentUserUUID = stored
|
|
90
|
+
val uid = prefs.getInt("userId", -1).takeIf { it != -1 }
|
|
91
|
+
userId = uid
|
|
92
|
+
Log.d(TAG, "Resuming session for UUID: $stored")
|
|
93
|
+
startBeaconScanning()
|
|
94
|
+
scanning = true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Turbo Module methods ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
override fun startScanner(userUUID: String, userId: Double?, promise: Promise) {
|
|
100
|
+
if (scanning) { promise.resolve("Already scanning"); return }
|
|
101
|
+
|
|
102
|
+
currentUserUUID = userUUID
|
|
103
|
+
this.userId = userId?.toInt()
|
|
104
|
+
|
|
105
|
+
val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE).edit()
|
|
106
|
+
prefs.putString("userUUID", userUUID)
|
|
107
|
+
this.userId?.let { prefs.putInt("userId", it) } ?: prefs.remove("userId")
|
|
108
|
+
prefs.apply()
|
|
109
|
+
|
|
110
|
+
startBeaconScanning()
|
|
111
|
+
scanning = true
|
|
112
|
+
Log.d(TAG, "Started scanning for $userUUID")
|
|
113
|
+
promise.resolve("Scanning started")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override fun stopScanner(promise: Promise) {
|
|
117
|
+
try {
|
|
118
|
+
beaconManager.removeAllMonitorNotifiers()
|
|
119
|
+
beaconManager.removeAllRangeNotifiers()
|
|
120
|
+
beaconManager.stopRangingBeaconsInRegion(Region("SpotnySDK_General", null, null, null))
|
|
121
|
+
beaconManager.stopMonitoringBeaconsInRegion(Region("SpotnySDK_General", null, null, null))
|
|
122
|
+
} catch (_: RemoteException) {}
|
|
123
|
+
|
|
124
|
+
cleanupAllState()
|
|
125
|
+
|
|
126
|
+
val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE).edit()
|
|
127
|
+
prefs.remove("userUUID"); prefs.remove("userId"); prefs.apply()
|
|
128
|
+
|
|
129
|
+
scanning = false; currentUserUUID = null; userId = null
|
|
130
|
+
Log.d(TAG, "Stopped scanning")
|
|
131
|
+
promise.resolve("Scanning stopped")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
override fun isScanning(promise: Promise) = promise.resolve(scanning)
|
|
135
|
+
|
|
136
|
+
override fun configure(config: ReadableMap?, promise: Promise) {
|
|
137
|
+
config?.getString("backendURL")?.let { backendURL = it; Log.d(TAG, "backendURL = $it") }
|
|
138
|
+
config?.getDouble("maxDetectionDistance").takeIf { config?.hasKey("maxDetectionDistance") == true }
|
|
139
|
+
?.let { maxDetectionDistance = it; Log.d(TAG, "maxDetectionDistance = $it m") }
|
|
140
|
+
promise.resolve("Configuration updated")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override fun requestNotificationPermissions(promise: Promise) {
|
|
144
|
+
// On Android, notification permission is handled at the app level (API 33+).
|
|
145
|
+
// We just resolve — the app should request POST_NOTIFICATIONS separately.
|
|
146
|
+
promise.resolve("handled_by_app")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override fun getDebugLogs(promise: Promise) {
|
|
150
|
+
val file = logFile()
|
|
151
|
+
promise.resolve(if (file.exists()) file.readText() else "No logs found")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override fun clearDebugLogs(promise: Promise) {
|
|
155
|
+
logFile().takeIf { it.exists() }?.delete()
|
|
156
|
+
promise.resolve("Logs cleared")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
override fun setDebounceInterval(interval: Double, promise: Promise) {
|
|
160
|
+
debounceInterval = interval.toLong() * 1000L
|
|
161
|
+
promise.resolve("Debounce interval set to ${interval}s")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override fun clearDebounceCache(promise: Promise) {
|
|
165
|
+
lastCampaignFetchAttempt.clear(); fetchInProgress.clear()
|
|
166
|
+
promise.resolve("Debounce cache cleared")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
override fun getDebounceStatus(promise: Promise) {
|
|
170
|
+
val map = WritableNativeMap()
|
|
171
|
+
for ((key, _) in activeCampaigns) {
|
|
172
|
+
val entry = WritableNativeMap()
|
|
173
|
+
lastCampaignFetchAttempt[key]?.let { entry.putDouble("lastFetchAttempt", it.toDouble()) }
|
|
174
|
+
fetchInProgress[key]?.let { entry.putBoolean("fetchInProgress", it) }
|
|
175
|
+
lastProximityEventSent[key]?.let { entry.putDouble("lastProximityEvent", it.toDouble()) }
|
|
176
|
+
lastImpressionEventSent[key]?.let { entry.putDouble("lastImpressionEvent", it.toDouble()) }
|
|
177
|
+
map.putMap(key, entry)
|
|
178
|
+
}
|
|
179
|
+
promise.resolve(map)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Required by NativeEventEmitter contract in new-arch
|
|
183
|
+
override fun addListener(eventName: String) {}
|
|
184
|
+
override fun removeListeners(count: Double) {}
|
|
185
|
+
|
|
186
|
+
// ── Beacon scanning ──────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
private fun startBeaconScanning() {
|
|
189
|
+
val region = Region("SpotnySDK_General", Identifier.parse(BEACON_UUID), null, null)
|
|
190
|
+
|
|
191
|
+
beaconManager.addRangeNotifier(rangeNotifier)
|
|
192
|
+
beaconManager.addMonitorNotifier(monitorNotifier)
|
|
193
|
+
|
|
194
|
+
beaconManager.startRangingBeaconsInRegion(region)
|
|
195
|
+
beaconManager.startMonitoringBeaconsInRegion(region)
|
|
196
|
+
|
|
197
|
+
Log.d(TAG, "Scanning for UUID $BEACON_UUID")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Range notifier ────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
private val rangeNotifier = RangeNotifier { beacons, region ->
|
|
203
|
+
val deviceId = getDeviceId()
|
|
204
|
+
val now = System.currentTimeMillis()
|
|
205
|
+
|
|
206
|
+
// JS event payload
|
|
207
|
+
val jsBeacons = WritableNativeArray()
|
|
208
|
+
for (beacon in beacons) {
|
|
209
|
+
val raw = beacon.distance
|
|
210
|
+
val adjusted = raw * 0.5
|
|
211
|
+
if (adjusted <= 0 || adjusted > maxDetectionDistance) continue
|
|
212
|
+
|
|
213
|
+
val b = WritableNativeMap().apply {
|
|
214
|
+
putString("uuid", beacon.id1?.toString() ?: BEACON_UUID)
|
|
215
|
+
putInt("major", beacon.id2?.toInt() ?: 0)
|
|
216
|
+
putInt("minor", beacon.id3?.toInt() ?: 0)
|
|
217
|
+
putDouble("distance", adjusted)
|
|
218
|
+
putInt("rssi", beacon.rssi)
|
|
219
|
+
putString("proximity", proximityLabel(adjusted))
|
|
220
|
+
}
|
|
221
|
+
jsBeacons.pushMap(b)
|
|
222
|
+
}
|
|
223
|
+
if (jsBeacons.size() > 0) {
|
|
224
|
+
val payload = WritableNativeMap().apply {
|
|
225
|
+
putArray("beacons", jsBeacons)
|
|
226
|
+
putString("region", region.uniqueId)
|
|
227
|
+
}
|
|
228
|
+
sendEvent("onBeaconsRanged", payload)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Per-beacon logic
|
|
232
|
+
for (beacon in beacons) {
|
|
233
|
+
val major = beacon.id2?.toInt() ?: continue
|
|
234
|
+
val minor = beacon.id3?.toInt() ?: continue
|
|
235
|
+
val key = beaconKey(major, minor)
|
|
236
|
+
val distance = beacon.distance * 0.5
|
|
237
|
+
|
|
238
|
+
if (distance <= 0 || distance > maxDetectionDistance) continue
|
|
239
|
+
|
|
240
|
+
if (activeCampaigns.containsKey(key)) {
|
|
241
|
+
val isFirst = !lastProximityEventSent.containsKey(key)
|
|
242
|
+
if (isFirst) {
|
|
243
|
+
sendProximity("NEARBY", key, distance, deviceId)
|
|
244
|
+
lastProximityDistance[key] = distance
|
|
245
|
+
lastProximityEventSent[key] = now
|
|
246
|
+
} else if (distance >= 1.0) {
|
|
247
|
+
val lastDist = lastProximityDistance[key] ?: 0.0
|
|
248
|
+
if (abs(distance - lastDist) >= proximityDistanceThreshold) {
|
|
249
|
+
sendProximity("NEARBY", key, distance, deviceId)
|
|
250
|
+
lastProximityDistance[key] = distance
|
|
251
|
+
lastProximityEventSent[key] = now
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
val campaign = activeCampaigns[key]
|
|
255
|
+
if (campaign?.campaignId != null && campaign.inQueue == false && distance <= impressionDistance) {
|
|
256
|
+
val lastImp = lastImpressionEventSent[key]
|
|
257
|
+
if (lastImp == null || now - lastImp >= impressionEventInterval) {
|
|
258
|
+
sendImpression(key, distance, deviceId)
|
|
259
|
+
lastImpressionEventSent[key] = now
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
fetchCampaign(major, minor, deviceId)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Monitor notifier ──────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
private val monitorNotifier = object : MonitorNotifier {
|
|
271
|
+
override fun didEnterRegion(region: Region) {
|
|
272
|
+
Log.d(TAG, "Entered region: ${region.uniqueId}")
|
|
273
|
+
val payload = WritableNativeMap().apply {
|
|
274
|
+
putString("region", region.uniqueId)
|
|
275
|
+
putString("event", "enter")
|
|
276
|
+
}
|
|
277
|
+
sendEvent("onBeaconRegionEvent", payload)
|
|
278
|
+
try { beaconManager.startRangingBeaconsInRegion(region) } catch (_: RemoteException) {}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
override fun didExitRegion(region: Region) {
|
|
282
|
+
Log.d(TAG, "Exited region: ${region.uniqueId}")
|
|
283
|
+
val payload = WritableNativeMap().apply {
|
|
284
|
+
putString("region", region.uniqueId)
|
|
285
|
+
putString("event", "exit")
|
|
286
|
+
}
|
|
287
|
+
sendEvent("onBeaconRegionEvent", payload)
|
|
288
|
+
val deviceId = getDeviceId()
|
|
289
|
+
for (key in activeCampaigns.keys.toList()) {
|
|
290
|
+
cleanupBeacon(key, deviceId)
|
|
291
|
+
}
|
|
292
|
+
try { beaconManager.stopRangingBeaconsInRegion(region) } catch (_: RemoteException) {}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
override fun didDetermineStateForRegion(state: Int, region: Region) {
|
|
296
|
+
val label = if (state == MonitorNotifier.INSIDE) "inside" else "outside"
|
|
297
|
+
val payload = WritableNativeMap().apply {
|
|
298
|
+
putString("region", region.uniqueId)
|
|
299
|
+
putString("event", "determined")
|
|
300
|
+
putString("state", label)
|
|
301
|
+
}
|
|
302
|
+
sendEvent("onBeaconRegionEvent", payload)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
private fun beaconKey(major: Int, minor: Int) = "${major}_${minor}"
|
|
309
|
+
|
|
310
|
+
private fun proximityLabel(distance: Double) = when {
|
|
311
|
+
distance < 0 -> "unknown"
|
|
312
|
+
distance < 0.5 -> "immediate"
|
|
313
|
+
distance < 3.0 -> "near"
|
|
314
|
+
else -> "far"
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private fun getDeviceId(): String {
|
|
318
|
+
val prefs = reactContext.getSharedPreferences("SpotnySDK", Context.MODE_PRIVATE)
|
|
319
|
+
return prefs.getString("deviceId", null) ?: run {
|
|
320
|
+
val id = UUID.randomUUID().toString()
|
|
321
|
+
prefs.edit().putString("deviceId", id).apply()
|
|
322
|
+
id
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private fun sendEvent(name: String, payload: WritableMap) {
|
|
327
|
+
reactContext
|
|
328
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
329
|
+
.emit(name, payload)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private fun logFile() = File(reactContext.filesDir, "spotny_beacon_debug.log")
|
|
333
|
+
|
|
334
|
+
private fun logToFile(message: String) {
|
|
335
|
+
ioExecutor.execute {
|
|
336
|
+
try {
|
|
337
|
+
val ts = SimpleDateFormat("yy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
|
338
|
+
logFile().appendText("[$ts] $message\n")
|
|
339
|
+
} catch (_: Exception) {}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── State cleanup ─────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
private fun cleanupBeacon(key: String, deviceId: String) {
|
|
346
|
+
sendProximity("PROXIMITY_EXIT", key, 0.0, deviceId)
|
|
347
|
+
activeCampaigns.remove(key)
|
|
348
|
+
lastProximityEventSent.remove(key)
|
|
349
|
+
lastProximityDistance.remove(key)
|
|
350
|
+
lastImpressionEventSent.remove(key)
|
|
351
|
+
lastCampaignFetchAttempt.remove(key)
|
|
352
|
+
fetchInProgress.remove(key)
|
|
353
|
+
proximityEventInProgress.remove(key)
|
|
354
|
+
impressionEventInProgress.remove(key)
|
|
355
|
+
Log.d(TAG, "Cleaned up state for beacon $key")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private fun cleanupAllState() {
|
|
359
|
+
val deviceId = getDeviceId()
|
|
360
|
+
activeCampaigns.keys.toList().forEach { cleanupBeacon(it, deviceId) }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Backend API ───────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
private fun post(
|
|
366
|
+
endpoint: String,
|
|
367
|
+
payload: Map<String, Any?>,
|
|
368
|
+
completion: (Int, String) -> Unit
|
|
369
|
+
) {
|
|
370
|
+
ioExecutor.execute {
|
|
371
|
+
try {
|
|
372
|
+
val conn = (URL("$backendURL$endpoint").openConnection() as HttpURLConnection).apply {
|
|
373
|
+
requestMethod = "POST"
|
|
374
|
+
setRequestProperty("Content-Type", "application/json")
|
|
375
|
+
connectTimeout = 10_000
|
|
376
|
+
readTimeout = 10_000
|
|
377
|
+
doOutput = true
|
|
378
|
+
}
|
|
379
|
+
val body = buildJsonString(payload)
|
|
380
|
+
conn.outputStream.use { it.write(body.toByteArray()) }
|
|
381
|
+
val status = conn.responseCode
|
|
382
|
+
val response = try { conn.inputStream.bufferedReader().readText() }
|
|
383
|
+
catch (_: Exception) { conn.errorStream?.bufferedReader()?.readText() ?: "" }
|
|
384
|
+
conn.disconnect()
|
|
385
|
+
reactContext.runOnUiQueueThread { completion(status, response) }
|
|
386
|
+
} catch (e: Exception) {
|
|
387
|
+
Log.e(TAG, "POST $endpoint error: ${e.message}")
|
|
388
|
+
reactContext.runOnUiQueueThread { completion(-1, "") }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Minimal JSON serialiser (avoids adding a full JSON library dependency). */
|
|
394
|
+
private fun buildJsonString(map: Map<String, Any?>): String {
|
|
395
|
+
val sb = StringBuilder("{")
|
|
396
|
+
map.entries.forEachIndexed { i, (k, v) ->
|
|
397
|
+
if (i > 0) sb.append(",")
|
|
398
|
+
sb.append("\"$k\":")
|
|
399
|
+
when (v) {
|
|
400
|
+
null -> sb.append("null")
|
|
401
|
+
is String -> sb.append("\"${v.replace("\"", "\\\"")}\"")
|
|
402
|
+
is Boolean -> sb.append(v)
|
|
403
|
+
is Number -> sb.append(v)
|
|
404
|
+
else -> sb.append("\"$v\"")
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
sb.append("}")
|
|
408
|
+
return sb.toString()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Campaign fetching ─────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
private fun fetchCampaign(major: Int, minor: Int, deviceId: String) {
|
|
414
|
+
val key = beaconKey(major, minor)
|
|
415
|
+
if (activeCampaigns.containsKey(key)) return
|
|
416
|
+
if (fetchInProgress[key] == true) return
|
|
417
|
+
|
|
418
|
+
val last = lastCampaignFetchAttempt[key]
|
|
419
|
+
if (last != null && System.currentTimeMillis() - last < campaignFetchCooldown) return
|
|
420
|
+
|
|
421
|
+
lastCampaignFetchAttempt[key] = System.currentTimeMillis()
|
|
422
|
+
fetchInProgress[key] = true
|
|
423
|
+
|
|
424
|
+
val payload = mutableMapOf<String, Any?>("beacon_id" to key, "device_id" to deviceId)
|
|
425
|
+
userId?.let { payload["user_id"] = it }
|
|
426
|
+
|
|
427
|
+
post("/api/app/campaigns/beacon", payload) { status, body ->
|
|
428
|
+
fetchInProgress[key] = false
|
|
429
|
+
if (status != 200) {
|
|
430
|
+
Log.w(TAG, "Campaign fetch status $status for beacon $key")
|
|
431
|
+
return@post
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
val json = parseJsonObject(body) ?: return@post
|
|
435
|
+
val dataObj = json["data"] as? Map<*, *> ?: return@post
|
|
436
|
+
val screen = dataObj["screen"] as? Map<*, *> ?: return@post
|
|
437
|
+
val screenId = (screen["id"] as? Number)?.toInt() ?: return@post
|
|
438
|
+
|
|
439
|
+
var campaignId: Int? = null
|
|
440
|
+
var inQueue = false
|
|
441
|
+
val campaignObj = dataObj["campaign"] as? Map<*, *>
|
|
442
|
+
if (campaignObj != null) {
|
|
443
|
+
campaignId = (campaignObj["id"] as? Number)?.toInt()
|
|
444
|
+
inQueue = campaignObj["inQueue"] as? Boolean ?: false
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
activeCampaigns[key] = CampaignData(
|
|
448
|
+
campaignId = campaignId, screenId = screenId,
|
|
449
|
+
sessionId = null, inQueue = inQueue, major = major, minor = minor)
|
|
450
|
+
Log.d(TAG, "Campaign loaded for beacon $key — screenId=$screenId")
|
|
451
|
+
} catch (e: Exception) {
|
|
452
|
+
Log.e(TAG, "Campaign JSON parse error for $key: ${e.message}")
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Tracking events ───────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
private fun sendTracking(
|
|
460
|
+
eventType: String,
|
|
461
|
+
key: String,
|
|
462
|
+
distance: Double,
|
|
463
|
+
deviceId: String,
|
|
464
|
+
endpoint: String
|
|
465
|
+
) {
|
|
466
|
+
val isImpression = eventType == "IMPRESSION_HEARTBEAT"
|
|
467
|
+
if (isImpression && impressionEventInProgress[key] == true) return
|
|
468
|
+
if (!isImpression && proximityEventInProgress[key] == true) return
|
|
469
|
+
|
|
470
|
+
if (isImpression) impressionEventInProgress[key] = true
|
|
471
|
+
else proximityEventInProgress[key] = true
|
|
472
|
+
|
|
473
|
+
val campaign = activeCampaigns[key]
|
|
474
|
+
if (campaign == null) {
|
|
475
|
+
if (isImpression) impressionEventInProgress[key] = false
|
|
476
|
+
else proximityEventInProgress[key] = false
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
if (isImpression && (campaign.campaignId == null || campaign.inQueue)) {
|
|
480
|
+
impressionEventInProgress[key] = false; return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
val payload = mutableMapOf<String, Any?>(
|
|
484
|
+
"event_type" to eventType,
|
|
485
|
+
"device_id" to deviceId,
|
|
486
|
+
"distance" to distance,
|
|
487
|
+
"screen_id" to campaign.screenId
|
|
488
|
+
)
|
|
489
|
+
campaign.campaignId?.let { payload["campaign_id"] = it }
|
|
490
|
+
campaign.sessionId?.let { payload["session_id"] = it }
|
|
491
|
+
userId?.let { payload["user_id"] = it }
|
|
492
|
+
|
|
493
|
+
post(endpoint, payload) { status, body ->
|
|
494
|
+
if (status in 200..299) {
|
|
495
|
+
Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
|
|
496
|
+
if (!isImpression && campaign.sessionId == null) {
|
|
497
|
+
try {
|
|
498
|
+
val json = parseJsonObject(body)
|
|
499
|
+
val sid = ((json?.get("data") as? Map<*, *>)
|
|
500
|
+
?.get("event") as? Map<*, *>)
|
|
501
|
+
?.get("session_id") as? String
|
|
502
|
+
if (sid != null) {
|
|
503
|
+
activeCampaigns[key] = campaign.copy(sessionId = sid)
|
|
504
|
+
Log.d(TAG, "session_id = $sid")
|
|
505
|
+
}
|
|
506
|
+
} catch (_: Exception) {}
|
|
507
|
+
}
|
|
508
|
+
} else if (status == 429) {
|
|
509
|
+
val penalty = System.currentTimeMillis() + 10_000L
|
|
510
|
+
if (isImpression) lastImpressionEventSent[key] = penalty
|
|
511
|
+
else lastProximityEventSent[key] = penalty
|
|
512
|
+
Log.w(TAG, "$eventType rate-limited (429)")
|
|
513
|
+
} else {
|
|
514
|
+
Log.w(TAG, "$eventType failed — status $status")
|
|
515
|
+
}
|
|
516
|
+
if (isImpression) impressionEventInProgress[key] = false
|
|
517
|
+
else proximityEventInProgress[key] = false
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private fun sendProximity(eventType: String, key: String, distance: Double, deviceId: String) =
|
|
522
|
+
sendTracking(eventType, key, distance, deviceId, "/api/app/impressions/proximity")
|
|
523
|
+
|
|
524
|
+
private fun sendImpression(key: String, distance: Double, deviceId: String) =
|
|
525
|
+
sendTracking("IMPRESSION_HEARTBEAT", key, distance, deviceId, "/api/app/impressions/track")
|
|
526
|
+
|
|
527
|
+
// ── Minimal JSON parser (avoids org.json / GSON dependency) ──────────────
|
|
528
|
+
// Only handles flat and one-level-deep objects. For this SDK, that is enough.
|
|
529
|
+
|
|
530
|
+
@Suppress("UNCHECKED_CAST")
|
|
531
|
+
private fun parseJsonObject(json: String): Map<String, Any?>? {
|
|
532
|
+
return try {
|
|
533
|
+
org.json.JSONObject(json).toMap()
|
|
534
|
+
} catch (_: Exception) { null }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private fun org.json.JSONObject.toMap(): Map<String, Any?> {
|
|
538
|
+
val map = mutableMapOf<String, Any?>()
|
|
539
|
+
keys().forEach { key ->
|
|
540
|
+
map[key] = when (val v = get(key)) {
|
|
541
|
+
is org.json.JSONObject -> v.toMap()
|
|
542
|
+
is org.json.JSONArray -> v.toList()
|
|
543
|
+
org.json.JSONObject.NULL -> null
|
|
544
|
+
else -> v
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return map
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private fun org.json.JSONArray.toList(): List<Any?> {
|
|
551
|
+
return (0 until length()).map { i ->
|
|
552
|
+
when (val v = get(i)) {
|
|
553
|
+
is org.json.JSONObject -> v.toMap()
|
|
554
|
+
is org.json.JSONArray -> v.toList()
|
|
555
|
+
org.json.JSONObject.NULL -> null
|
|
556
|
+
else -> v
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
companion object {
|
|
562
|
+
const val NAME = NativeSpotnySdkSpec.NAME
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.spotnysdk
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import java.util.HashMap
|
|
9
|
+
|
|
10
|
+
class SpotnySdkPackage : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
12
|
+
return if (name == SpotnySdkModule.NAME) {
|
|
13
|
+
SpotnySdkModule(reactContext)
|
|
14
|
+
} else {
|
|
15
|
+
null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
20
|
+
mapOf(
|
|
21
|
+
SpotnySdkModule.NAME to ReactModuleInfo(
|
|
22
|
+
name = SpotnySdkModule.NAME,
|
|
23
|
+
className = SpotnySdkModule.NAME,
|
|
24
|
+
canOverrideExistingModule = false,
|
|
25
|
+
needsEagerInit = false,
|
|
26
|
+
isCxxModule = false,
|
|
27
|
+
isTurboModule = true
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|