expo-beacon 0.6.8 → 0.6.10
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/android/src/main/java/expo/modules/beacon/BeaconApiForwarder.kt +101 -0
- package/android/src/main/java/expo/modules/beacon/BeaconConstants.kt +3 -0
- package/android/src/main/java/expo/modules/beacon/BeaconEventReceiver.kt +5 -2
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +67 -13
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +12 -0
- package/build/ExpoBeacon.types.d.ts +16 -0
- package/build/ExpoBeacon.types.d.ts.map +1 -1
- package/build/ExpoBeacon.types.js.map +1 -1
- package/build/ExpoBeaconModule.d.ts +9 -0
- package/build/ExpoBeaconModule.d.ts.map +1 -1
- package/build/ExpoBeaconModule.js.map +1 -1
- package/ios/BeaconApiForwarder.swift +104 -0
- package/ios/ExpoBeaconModule.swift +107 -19
- package/package.json +1 -1
- package/src/ExpoBeacon.types.ts +16 -0
- package/src/ExpoBeaconModule.ts +10 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package expo.modules.beacon
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
import java.io.OutputStreamWriter
|
|
9
|
+
import java.net.HttpURLConnection
|
|
10
|
+
import java.net.URL
|
|
11
|
+
import java.util.concurrent.Executors
|
|
12
|
+
|
|
13
|
+
private const val API_PREFS = "expo.beacon.api_config"
|
|
14
|
+
private const val API_URL_KEY = "api_url"
|
|
15
|
+
private const val API_KEY_KEY = "api_key"
|
|
16
|
+
private const val MAX_RETRIES = 3
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fire-and-forget HTTP event forwarder for beacon events.
|
|
20
|
+
* Sends enter/exit/timeout events to a configured API endpoint from native code,
|
|
21
|
+
* ensuring delivery even when the JS bridge is not active (app backgrounded).
|
|
22
|
+
*/
|
|
23
|
+
internal class BeaconApiForwarder(private val context: Context) {
|
|
24
|
+
|
|
25
|
+
private val executor = Executors.newSingleThreadExecutor()
|
|
26
|
+
private val prefs: SharedPreferences by lazy {
|
|
27
|
+
context.getSharedPreferences(API_PREFS, Context.MODE_PRIVATE)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
val isConfigured: Boolean
|
|
31
|
+
get() = prefs.getString(API_URL_KEY, null)?.isNotEmpty() == true
|
|
32
|
+
|
|
33
|
+
fun configure(url: String, apiKey: String?) {
|
|
34
|
+
prefs.edit().apply {
|
|
35
|
+
putString(API_URL_KEY, url)
|
|
36
|
+
if (apiKey != null) putString(API_KEY_KEY, apiKey)
|
|
37
|
+
else remove(API_KEY_KEY)
|
|
38
|
+
}.apply()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun getConfig(): Map<String, String?> {
|
|
42
|
+
return mapOf(
|
|
43
|
+
"url" to prefs.getString(API_URL_KEY, null),
|
|
44
|
+
"apiKey" to prefs.getString(API_KEY_KEY, null)
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Send a beacon event to the configured API endpoint.
|
|
50
|
+
* Fire-and-forget with simple retry (3 attempts, exponential backoff).
|
|
51
|
+
* No-op if no endpoint is configured.
|
|
52
|
+
*/
|
|
53
|
+
fun forwardEvent(params: Map<String, Any?>) {
|
|
54
|
+
val url = prefs.getString(API_URL_KEY, null)
|
|
55
|
+
if (url.isNullOrEmpty()) return
|
|
56
|
+
|
|
57
|
+
val apiKey = prefs.getString(API_KEY_KEY, null)
|
|
58
|
+
val payload = JSONObject().apply {
|
|
59
|
+
for ((k, v) in params) {
|
|
60
|
+
put(k, v ?: JSONObject.NULL)
|
|
61
|
+
}
|
|
62
|
+
put("timestamp", System.currentTimeMillis())
|
|
63
|
+
put("platform", "android")
|
|
64
|
+
put("sdkVersion", Build.VERSION.SDK_INT)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
executor.execute {
|
|
68
|
+
var lastException: Exception? = null
|
|
69
|
+
for (attempt in 1..MAX_RETRIES) {
|
|
70
|
+
try {
|
|
71
|
+
val conn = URL(url).openConnection() as HttpURLConnection
|
|
72
|
+
conn.apply {
|
|
73
|
+
requestMethod = "POST"
|
|
74
|
+
setRequestProperty("Content-Type", "application/json")
|
|
75
|
+
apiKey?.let { setRequestProperty("X-API-Key", it) }
|
|
76
|
+
connectTimeout = 10_000
|
|
77
|
+
readTimeout = 10_000
|
|
78
|
+
doOutput = true
|
|
79
|
+
}
|
|
80
|
+
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
|
81
|
+
val code = conn.responseCode
|
|
82
|
+
conn.disconnect()
|
|
83
|
+
if (code in 200..299) return@execute
|
|
84
|
+
// 4xx client errors — don't retry
|
|
85
|
+
if (code in 400..499) {
|
|
86
|
+
Log.w(TAG, "API forward failed with $code — not retrying")
|
|
87
|
+
return@execute
|
|
88
|
+
}
|
|
89
|
+
lastException = RuntimeException("HTTP $code")
|
|
90
|
+
} catch (e: Exception) {
|
|
91
|
+
lastException = e
|
|
92
|
+
}
|
|
93
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
94
|
+
if (attempt < MAX_RETRIES) {
|
|
95
|
+
try { Thread.sleep(1000L * (1 shl (attempt - 1))) } catch (_: InterruptedException) {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
Log.w(TAG, "API forward failed after $MAX_RETRIES attempts: ${lastException?.message}")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -47,6 +47,9 @@ internal const val HYSTERESIS_COUNT = 3
|
|
|
47
47
|
*/
|
|
48
48
|
internal const val REGION_EXIT_PERIOD_MS = 60000L
|
|
49
49
|
|
|
50
|
+
/** Default minimum RSSI (dBm) below which beacon readings are discarded as unreliable. */
|
|
51
|
+
internal const val DEFAULT_MIN_RSSI = -85
|
|
52
|
+
|
|
50
53
|
/** Shared log tag for the expo-beacon module. */
|
|
51
54
|
internal const val TAG = "ExpoBeacon"
|
|
52
55
|
|
|
@@ -24,6 +24,7 @@ class BeaconEventReceiver(
|
|
|
24
24
|
val eventType = intent.getStringExtra("event") ?: return
|
|
25
25
|
val beaconType = intent.getStringExtra("beaconType") ?: "ibeacon"
|
|
26
26
|
val distance = intent.getDoubleExtra("distance", -1.0)
|
|
27
|
+
val rssi = intent.getIntExtra("rssi", 0)
|
|
27
28
|
|
|
28
29
|
if (beaconType == "eddystone") {
|
|
29
30
|
val namespace = intent.getStringExtra("namespace") ?: ""
|
|
@@ -34,7 +35,8 @@ class BeaconEventReceiver(
|
|
|
34
35
|
"namespace" to namespace,
|
|
35
36
|
"instance" to instance,
|
|
36
37
|
"event" to eventType,
|
|
37
|
-
"distance" to distance
|
|
38
|
+
"distance" to distance,
|
|
39
|
+
"rssi" to rssi
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
val eventName = when (eventType) {
|
|
@@ -56,7 +58,8 @@ class BeaconEventReceiver(
|
|
|
56
58
|
"major" to major,
|
|
57
59
|
"minor" to minor,
|
|
58
60
|
"event" to eventType,
|
|
59
|
-
"distance" to distance
|
|
61
|
+
"distance" to distance,
|
|
62
|
+
"rssi" to rssi
|
|
60
63
|
)
|
|
61
64
|
|
|
62
65
|
val eventName = when (eventType) {
|
|
@@ -38,6 +38,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
38
38
|
// Distance filtering
|
|
39
39
|
@Volatile private var maxDistance: Double? = null
|
|
40
40
|
@Volatile private var exitDistance: Double? = null
|
|
41
|
+
@Volatile private var minRssiThreshold: Int = DEFAULT_MIN_RSSI
|
|
41
42
|
private val monitoredRegionIds = java.util.concurrent.CopyOnWriteArraySet<String>()
|
|
42
43
|
private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
|
|
43
44
|
private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
|
@@ -48,6 +49,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
48
49
|
private val exitCounters = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
|
49
50
|
private val missCounters = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
|
50
51
|
|
|
52
|
+
// Distance smoothing (EMA)
|
|
53
|
+
private val smoothedDistances = java.util.concurrent.ConcurrentHashMap<String, Double>()
|
|
54
|
+
|
|
51
55
|
// Notification ID counter for unique per-beacon notifications
|
|
52
56
|
private val notifIdCounter = AtomicInteger(0)
|
|
53
57
|
private val notifIdMap = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
|
@@ -61,10 +65,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
61
65
|
// Per-beacon timeout seconds lookup (identifier → seconds), loaded from paired data
|
|
62
66
|
private val beaconTimeouts = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
|
63
67
|
private var eventLogger: BeaconEventLogger? = null
|
|
68
|
+
private var apiForwarder: BeaconApiForwarder? = null
|
|
64
69
|
|
|
65
70
|
override fun onCreate() {
|
|
66
71
|
super.onCreate()
|
|
67
72
|
createNotificationChannel()
|
|
73
|
+
apiForwarder = BeaconApiForwarder(this)
|
|
68
74
|
beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
|
|
69
75
|
BeaconParsers.ensureRegistered(manager)
|
|
70
76
|
try { manager.setEnableScheduledScanJobs(false) } catch (e: IllegalStateException) { Log.w(TAG, "setEnableScheduledScanJobs failed", e) }
|
|
@@ -114,10 +120,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
114
120
|
|
|
115
121
|
override fun onBeaconServiceConnect() {
|
|
116
122
|
serviceConnected = true
|
|
117
|
-
// Read max distance
|
|
123
|
+
// Read max distance, exit distance, and min RSSI from options prefs
|
|
118
124
|
val optPrefs = getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
|
|
119
125
|
maxDistance = optPrefs.getString("max_distance", null)?.toDoubleOrNull()
|
|
120
126
|
exitDistance = optPrefs.getString("exit_distance", null)?.toDoubleOrNull()
|
|
127
|
+
minRssiThreshold = optPrefs.getInt("min_rssi", DEFAULT_MIN_RSSI)
|
|
121
128
|
|
|
122
129
|
beaconManager.addMonitorNotifier(monitorNotifier)
|
|
123
130
|
beaconManager.addRangeNotifier(rangeNotifier)
|
|
@@ -171,6 +178,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
171
178
|
enterCounters.clear()
|
|
172
179
|
exitCounters.clear()
|
|
173
180
|
missCounters.clear()
|
|
181
|
+
smoothedDistances.clear()
|
|
174
182
|
}
|
|
175
183
|
// NOTE: enteredRegions is intentionally NOT cleared here.
|
|
176
184
|
// Clearing it on every reload (e.g. START_STICKY restart or repeated
|
|
@@ -249,10 +257,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
249
257
|
// Distance logging only — emits distance broadcasts. Enter/exit logic lives in rangeNotifier.
|
|
250
258
|
private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
|
|
251
259
|
if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
|
|
252
|
-
val closest = beacons.filter { it.distance >= 0 }.minByOrNull { it.distance }
|
|
260
|
+
val closest = beacons.filter { it.distance >= 0 && it.rssi >= minRssiThreshold }.minByOrNull { it.distance }
|
|
253
261
|
if (closest != null) {
|
|
254
262
|
lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
|
|
255
|
-
sendBeaconBroadcast(region, "distance", closest.distance)
|
|
263
|
+
sendBeaconBroadcast(region, "distance", closest.distance, closest.rssi)
|
|
256
264
|
}
|
|
257
265
|
}
|
|
258
266
|
|
|
@@ -299,7 +307,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
299
307
|
if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
|
|
300
308
|
|
|
301
309
|
val beacon = beacons
|
|
302
|
-
.filter { it.distance >= 0 }
|
|
310
|
+
.filter { it.distance >= 0 && it.rssi >= minRssiThreshold }
|
|
303
311
|
.minByOrNull { it.distance }
|
|
304
312
|
|
|
305
313
|
synchronized(distanceLock) {
|
|
@@ -308,18 +316,25 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
308
316
|
lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
|
|
309
317
|
missCounters[region.uniqueId] = 0
|
|
310
318
|
|
|
311
|
-
|
|
319
|
+
// Apply EMA smoothing; jump guard returns null for outliers
|
|
320
|
+
val smoothed = smoothDistance(region.uniqueId, beacon.distance)
|
|
321
|
+
if (smoothed == null) {
|
|
322
|
+
// Outlier — treat as miss without resetting enter counter
|
|
323
|
+
return@RangeNotifier
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
val action = evaluateDistanceHysteresis(region.uniqueId, smoothed, maxDist)
|
|
312
327
|
when (action) {
|
|
313
328
|
HysteresisAction.ENTER -> {
|
|
314
329
|
enteredRegions.add(region.uniqueId)
|
|
315
|
-
sendBeaconBroadcast(region, "enter", beacon.distance)
|
|
330
|
+
sendBeaconBroadcast(region, "enter", beacon.distance, beacon.rssi)
|
|
316
331
|
showEnterExitNotification(region, "enter")
|
|
317
332
|
scheduleTimeoutIfConfigured(region)
|
|
318
333
|
}
|
|
319
334
|
HysteresisAction.EXIT -> {
|
|
320
335
|
cancelTimeout(region.uniqueId)
|
|
321
336
|
enteredRegions.remove(region.uniqueId)
|
|
322
|
-
sendBeaconBroadcast(region, "exit", beacon.distance)
|
|
337
|
+
sendBeaconBroadcast(region, "exit", beacon.distance, beacon.rssi)
|
|
323
338
|
showEnterExitNotification(region, "exit")
|
|
324
339
|
}
|
|
325
340
|
HysteresisAction.NONE -> {}
|
|
@@ -333,7 +348,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
333
348
|
missCounters[region.uniqueId] = count
|
|
334
349
|
|
|
335
350
|
if (enteredRegions.contains(region.uniqueId) && count >= EXIT_MISS_THRESHOLD) {
|
|
336
|
-
|
|
351
|
+
// Do NOT cancel the timeout here. A miss-based exit is triggered by BLE
|
|
352
|
+
// scan gaps (unreliable signal disappearance), not a confirmed physical
|
|
353
|
+
// departure. Cancelling the timeout here would prevent it from ever firing
|
|
354
|
+
// when the configured timeout (e.g. 25 s) exceeds the miss window (~21 s).
|
|
355
|
+
// The timeout runnable fires unconditionally; distance-based exits still
|
|
356
|
+
// call cancelTimeout() reliably when the beacon moves out of range.
|
|
337
357
|
enteredRegions.remove(region.uniqueId)
|
|
338
358
|
missCounters[region.uniqueId] = 0
|
|
339
359
|
enterCounters[region.uniqueId] = 0
|
|
@@ -349,6 +369,26 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
349
369
|
|
|
350
370
|
private enum class HysteresisAction { NONE, ENTER, EXIT }
|
|
351
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Apply exponential moving average (EMA) smoothing to a raw distance reading.
|
|
374
|
+
* Returns null if the reading is a jump outlier (raw differs from smoothed by > DISTANCE_JUMP_FACTOR).
|
|
375
|
+
*/
|
|
376
|
+
private fun smoothDistance(regionId: String, rawDistance: Double): Double? {
|
|
377
|
+
val prev = smoothedDistances[regionId]
|
|
378
|
+
if (prev == null) {
|
|
379
|
+
smoothedDistances[regionId] = rawDistance
|
|
380
|
+
return rawDistance
|
|
381
|
+
}
|
|
382
|
+
// Jump guard: if the raw value is wildly different, treat as outlier
|
|
383
|
+
val ratio = if (prev > 0.001) rawDistance / prev else rawDistance
|
|
384
|
+
if (ratio > DISTANCE_JUMP_FACTOR || (ratio > 0 && ratio < 1.0 / DISTANCE_JUMP_FACTOR)) {
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
val smoothed = DISTANCE_EMA_ALPHA * rawDistance + (1 - DISTANCE_EMA_ALPHA) * prev
|
|
388
|
+
smoothedDistances[regionId] = smoothed
|
|
389
|
+
return smoothed
|
|
390
|
+
}
|
|
391
|
+
|
|
352
392
|
/**
|
|
353
393
|
* Computes the effective exit distance from maxDistance and an optional explicit exitDistance.
|
|
354
394
|
* Default: maxDistance + min(maxDistance × 0.5, 2.5).
|
|
@@ -423,10 +463,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
423
463
|
cancelTimeout(region.uniqueId)
|
|
424
464
|
val runnable = Runnable {
|
|
425
465
|
timeoutRunnables.remove(region.uniqueId)
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
466
|
+
// Fire unconditionally. A miss-based exit may have cleared enteredRegions before
|
|
467
|
+
// the timer elapsed (BLE gaps can cause false exits at ~21 s), but the beacon
|
|
468
|
+
// may still be physically present. Distance-based exits call cancelTimeout() so
|
|
469
|
+
// this runnable is never queued when the beacon has genuinely moved away.
|
|
470
|
+
sendBeaconBroadcast(region, "timeout", -1.0)
|
|
430
471
|
}
|
|
431
472
|
timeoutRunnables[region.uniqueId] = runnable
|
|
432
473
|
timeoutHandler.postDelayed(runnable, seconds * 1000L)
|
|
@@ -436,7 +477,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
436
477
|
timeoutRunnables.remove(regionId)?.let { timeoutHandler.removeCallbacks(it) }
|
|
437
478
|
}
|
|
438
479
|
|
|
439
|
-
private fun sendBeaconBroadcast(region: Region, eventType: String, distance: Double) {
|
|
480
|
+
private fun sendBeaconBroadcast(region: Region, eventType: String, distance: Double, rssi: Int = 0) {
|
|
440
481
|
// Determine if this is an Eddystone region based on identifier format
|
|
441
482
|
// Eddystone regions have id1 as a hex namespace (not a UUID)
|
|
442
483
|
val id1Str = region.id1?.toString() ?: ""
|
|
@@ -449,6 +490,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
449
490
|
put("instance", region.id2?.toString()?.removePrefix("0x") ?: "")
|
|
450
491
|
put("event", eventType)
|
|
451
492
|
put("distance", distance)
|
|
493
|
+
put("rssi", rssi)
|
|
452
494
|
}
|
|
453
495
|
} else {
|
|
454
496
|
buildMap<String, Any?> {
|
|
@@ -458,14 +500,21 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
458
500
|
put("minor", region.id3?.toInt() ?: 0)
|
|
459
501
|
put("event", eventType)
|
|
460
502
|
put("distance", distance)
|
|
503
|
+
put("rssi", rssi)
|
|
461
504
|
}
|
|
462
505
|
}
|
|
463
506
|
monitoringEventName(isEddystone, eventType)?.let { logBeaconEvent(it, params) }
|
|
464
507
|
|
|
508
|
+
// Forward enter/exit/timeout events to remote API (skip distance — too frequent)
|
|
509
|
+
if (eventType != "distance") {
|
|
510
|
+
apiForwarder?.forwardEvent(params)
|
|
511
|
+
}
|
|
512
|
+
|
|
465
513
|
val intent = Intent(ACTION_BEACON_EVENT).apply {
|
|
466
514
|
putExtra("identifier", region.uniqueId)
|
|
467
515
|
putExtra("event", eventType)
|
|
468
516
|
putExtra("distance", distance)
|
|
517
|
+
putExtra("rssi", rssi)
|
|
469
518
|
if (isEddystone) {
|
|
470
519
|
putExtra("beaconType", "eddystone")
|
|
471
520
|
putExtra("namespace", id1Str.removePrefix("0x"))
|
|
@@ -587,6 +636,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
587
636
|
}
|
|
588
637
|
|
|
589
638
|
companion object {
|
|
639
|
+
/** EMA weight for new readings. 0.4 balances responsiveness vs noise rejection. */
|
|
640
|
+
const val DISTANCE_EMA_ALPHA = 0.4
|
|
641
|
+
/** If raw distance differs from smoothed by more than this factor, treat as outlier. */
|
|
642
|
+
const val DISTANCE_JUMP_FACTOR = 5.0
|
|
643
|
+
|
|
590
644
|
private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
|
|
591
645
|
|
|
592
646
|
fun start(context: Context) {
|
|
@@ -281,6 +281,7 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
281
281
|
}
|
|
282
282
|
var maxDistance: Double? = null
|
|
283
283
|
var exitDistance: Double? = null
|
|
284
|
+
var minRssi: Int? = null
|
|
284
285
|
when (options) {
|
|
285
286
|
is Double -> maxDistance = options
|
|
286
287
|
is Map<*, *> -> {
|
|
@@ -288,6 +289,7 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
288
289
|
val map = options as Map<String, Any?>
|
|
289
290
|
maxDistance = (map["maxDistance"] as? Number)?.toDouble()
|
|
290
291
|
exitDistance = (map["exitDistance"] as? Number)?.toDouble()
|
|
292
|
+
minRssi = (map["minRssi"] as? Number)?.toInt()
|
|
291
293
|
val notifications = map["notifications"]
|
|
292
294
|
if (notifications is Map<*, *>) {
|
|
293
295
|
@Suppress("UNCHECKED_CAST")
|
|
@@ -318,6 +320,8 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
318
320
|
else remove("max_distance")
|
|
319
321
|
if (exitDistance != null) putString("exit_distance", exitDistance.toString())
|
|
320
322
|
else remove("exit_distance")
|
|
323
|
+
if (minRssi != null) putInt("min_rssi", minRssi)
|
|
324
|
+
else remove("min_rssi")
|
|
321
325
|
}.apply()
|
|
322
326
|
// Verify we have the permissions needed for background monitoring
|
|
323
327
|
val hasLocation = ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
@@ -449,6 +453,14 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
449
453
|
BeaconEventLogger.deleteLogDatabase(ctx)
|
|
450
454
|
}
|
|
451
455
|
|
|
456
|
+
// MARK: - API Forwarding
|
|
457
|
+
|
|
458
|
+
Function("setApiEndpoint") { url: String, apiKey: String? ->
|
|
459
|
+
val ctx = appContext.reactContext
|
|
460
|
+
?: throw IllegalStateException("React context is not available")
|
|
461
|
+
BeaconApiForwarder(ctx).configure(url, apiKey)
|
|
462
|
+
}
|
|
463
|
+
|
|
452
464
|
// MARK: - Battery Optimization
|
|
453
465
|
|
|
454
466
|
Function("isBatteryOptimizationExempt") {
|
|
@@ -38,6 +38,8 @@ export type BeaconRegionEvent = {
|
|
|
38
38
|
event: "enter" | "exit";
|
|
39
39
|
/** Measured distance in metres at the time of the event (–1 if unavailable). */
|
|
40
40
|
distance: number;
|
|
41
|
+
/** Signal strength in dBm at the time of the event (0 if unavailable). */
|
|
42
|
+
rssi?: number;
|
|
41
43
|
};
|
|
42
44
|
/** Payload for periodic distance update events during monitoring. */
|
|
43
45
|
export type BeaconDistanceEvent = {
|
|
@@ -46,6 +48,8 @@ export type BeaconDistanceEvent = {
|
|
|
46
48
|
major: number;
|
|
47
49
|
minor: number;
|
|
48
50
|
distance: number;
|
|
51
|
+
/** Signal strength in dBm (0 if unavailable). */
|
|
52
|
+
rssi?: number;
|
|
49
53
|
};
|
|
50
54
|
/** Payload for beacon timeout events (beacon in range for configured duration). */
|
|
51
55
|
export type BeaconTimeoutEvent = {
|
|
@@ -120,6 +124,14 @@ export type MonitoringOptions = {
|
|
|
120
124
|
* Only used when `maxDistance` is set.
|
|
121
125
|
*/
|
|
122
126
|
exitDistance?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Minimum RSSI (dBm) for a beacon reading to be considered valid.
|
|
129
|
+
* Readings below this threshold are discarded as unreliable, preventing
|
|
130
|
+
* false detections from reflected or distant signals.
|
|
131
|
+
*
|
|
132
|
+
* Default: -85. Typical range: -100 (very permissive) to -70 (strict).
|
|
133
|
+
*/
|
|
134
|
+
minRssi?: number;
|
|
123
135
|
/** Notification configuration overrides to apply for this monitoring session. */
|
|
124
136
|
notifications?: NotificationConfig;
|
|
125
137
|
};
|
|
@@ -169,6 +181,8 @@ export type EddystoneRegionEvent = {
|
|
|
169
181
|
event: "enter" | "exit";
|
|
170
182
|
/** Measured distance in metres at the time of the event (–1 if unavailable). */
|
|
171
183
|
distance: number;
|
|
184
|
+
/** Signal strength in dBm at the time of the event (0 if unavailable). */
|
|
185
|
+
rssi?: number;
|
|
172
186
|
};
|
|
173
187
|
/** Payload for periodic Eddystone distance update events during monitoring. */
|
|
174
188
|
export type EddystoneDistanceEvent = {
|
|
@@ -176,6 +190,8 @@ export type EddystoneDistanceEvent = {
|
|
|
176
190
|
namespace: string;
|
|
177
191
|
instance: string;
|
|
178
192
|
distance: number;
|
|
193
|
+
/** Signal strength in dBm (0 if unavailable). */
|
|
194
|
+
rssi?: number;
|
|
179
195
|
};
|
|
180
196
|
/** Payload for Eddystone timeout events (beacon in range for configured duration). */
|
|
181
197
|
export type EddystoneTimeoutEvent = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeacon.types.d.ts","sourceRoot":"","sources":["../src/ExpoBeacon.types.ts"],"names":[],"mappings":"AAAA,2CAA2C;AAC3C,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,0GAA0G;IAC1G,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,iBAAiB,GAAG;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;IACxB,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"ExpoBeacon.types.d.ts","sourceRoot":"","sources":["../src/ExpoBeacon.types.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeacon.types.js","sourceRoot":"","sources":["../src/ExpoBeacon.types.ts"],"names":[],"mappings":"","sourcesContent":["/** Raw beacon discovered during a scan. */\r\nexport type BeaconScanResult = {\r\n uuid: string; // iBeacon proximity UUID (uppercase, formatted)\r\n major: number; // iBeacon major value (0–65535)\r\n minor: number; // iBeacon minor value (0–65535)\r\n rssi: number; // Signal strength in dBm (negative number)\r\n distance: number; // Estimated distance in meters\r\n txPower: number; // Calibrated TX power\r\n /** BLE advertising device name. May be undefined on iOS (CoreLocation does not expose it for iBeacon). */\r\n name?: string;\r\n};\r\n\r\n/**\r\n * A beacon that has been paired/registered for monitoring.\r\n *\r\n * Note: Paired beacon data is stored unencrypted in UserDefaults (iOS) /\r\n * SharedPreferences (Android) and may be included in device backups.\r\n */\r\nexport type PairedBeacon = {\r\n identifier: string; // User-defined label (e.g. \"lobby-door\")\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n /** BLE advertising device name, if provided at pairing time. */\r\n name?: string;\r\n /**\r\n * Timeout in seconds. When set, the module fires `onBeaconTimeout` once\r\n * after the beacon has been continuously in range for this duration.\r\n * The timer resets if the beacon exits and re-enters range.\r\n */\r\n timeoutSeconds?: number;\r\n};\r\n\r\n/** Payload for enter/exit region events. */\r\nexport type BeaconRegionEvent = {\r\n identifier: string; // Matches PairedBeacon.identifier\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n event: \"enter\" | \"exit\";\r\n /** Measured distance in metres at the time of the event (–1 if unavailable). */\r\n distance: number;\r\n};\r\n\r\n/** Payload for periodic distance update events during monitoring. */\r\nexport type BeaconDistanceEvent = {\r\n identifier: string;\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n distance: number;\r\n};\r\n\r\n/** Payload for beacon timeout events (beacon in range for configured duration). */\r\nexport type BeaconTimeoutEvent = {\r\n identifier: string;\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n /** Current distance in metres at the time the timeout fired. */\r\n distance: number;\r\n};\r\n\r\n/** Configuration for beacon enter/exit event notifications. */\r\nexport type BeaconNotificationConfig = {\r\n /** Whether to show enter/exit notifications. Default: true. */\r\n enabled?: boolean;\r\n /** Notification title on beacon enter. Default: \"Beacon Entered\". */\r\n enterTitle?: string;\r\n /** Notification title on beacon exit. Default: \"Beacon Exited\". */\r\n exitTitle?: string;\r\n /**\r\n * Notification body template. Supports {identifier} and {event} placeholders.\r\n * Default: \"{identifier} region {event}ed\".\r\n */\r\n body?: string;\r\n /** Play a sound with the notification (iOS only). Default: true. */\r\n sound?: boolean;\r\n /** Android drawable resource name for the notification icon (e.g. \"ic_notification\"). */\r\n icon?: string;\r\n};\r\n\r\n/** Configuration for the Android foreground service notification (persistent status bar entry). */\r\nexport type ForegroundServiceConfig = {\r\n /** Title of the persistent notification. Default: \"Beacon Monitoring Active\". */\r\n title?: string;\r\n /** Body text of the persistent notification. Default: \"Monitoring for iBeacons in the background\". */\r\n text?: string;\r\n /** Android drawable resource name for the notification icon. */\r\n icon?: string;\r\n};\r\n\r\n/** Configuration for the Android notification channel. */\r\nexport type NotificationChannelConfig = {\r\n /** Channel display name shown in system settings. Default: \"Beacon Monitoring\". */\r\n name?: string;\r\n /** Channel description shown in system settings. Default: \"Used for background iBeacon region monitoring\". */\r\n description?: string;\r\n /**\r\n * Channel importance level. Default: 'low'.\r\n * Note: Android may ignore decreases in importance after first channel creation until the app is reinstalled.\r\n */\r\n importance?: \"low\" | \"default\" | \"high\";\r\n};\r\n\r\n/** Combined notification configuration for all notification types. */\r\nexport type NotificationConfig = {\r\n /** Settings for beacon enter/exit event notifications. */\r\n beaconEvents?: BeaconNotificationConfig;\r\n /** Settings for the persistent foreground service notification (Android only). */\r\n foregroundService?: ForegroundServiceConfig;\r\n /** Settings for the Android notification channel (Android only). */\r\n channel?: NotificationChannelConfig;\r\n};\r\n\r\n/** Options accepted by startMonitoring(). */\r\nexport type MonitoringOptions = {\r\n /**\r\n * Maximum distance in metres for distance-based enter events.\r\n * Exit events are always emitted when the region is lost.\r\n */\r\n maxDistance?: number;\r\n /**\r\n * Distance in metres at which exit events fire (must be ≥ maxDistance).\r\n * Creates a hysteresis band between enter and exit thresholds to prevent\r\n * rapid toggling near the boundary.\r\n *\r\n * Default when omitted: `maxDistance + min(maxDistance × 0.5, 2.5)`.\r\n * Only used when `maxDistance` is set.\r\n */\r\n exitDistance?: number;\r\n /** Notification configuration overrides to apply for this monitoring session. */\r\n notifications?: NotificationConfig;\r\n};\r\n\r\n/** Eddystone frame type. */\r\nexport type EddystoneFrameType = \"uid\" | \"url\";\r\n\r\n/** Raw Eddystone beacon discovered during a scan. */\r\nexport type EddystoneScanResult = {\r\n frameType: EddystoneFrameType;\r\n /** 10-byte namespace ID as hex string (20 chars). Present for UID frames. */\r\n namespace?: string;\r\n /** 6-byte instance ID as hex string (12 chars). Present for UID frames. */\r\n instance?: string;\r\n /** Decoded URL. Present for URL frames. */\r\n url?: string;\r\n rssi: number;\r\n distance: number;\r\n txPower: number;\r\n /** BLE advertising device name. */\r\n name?: string;\r\n};\r\n\r\n/**\r\n * An Eddystone-UID beacon that has been paired/registered for monitoring.\r\n *\r\n * Note: Paired beacon data is stored unencrypted in UserDefaults (iOS) /\r\n * SharedPreferences (Android) and may be included in device backups.\r\n */\r\nexport type PairedEddystone = {\r\n identifier: string;\r\n /** 10-byte namespace ID as hex string (20 chars). */\r\n namespace: string;\r\n /** 6-byte instance ID as hex string (12 chars). */\r\n instance: string;\r\n /** BLE advertising device name, if provided at pairing time. */\r\n name?: string;\r\n /**\r\n * Timeout in seconds. When set, the module fires `onEddystoneTimeout` once\r\n * after the beacon has been continuously in range for this duration.\r\n * The timer resets if the beacon exits and re-enters range.\r\n */\r\n timeoutSeconds?: number;\r\n};\r\n\r\n/** Payload for Eddystone enter/exit region events. */\r\nexport type EddystoneRegionEvent = {\r\n identifier: string;\r\n namespace: string;\r\n instance: string;\r\n event: \"enter\" | \"exit\";\r\n /** Measured distance in metres at the time of the event (–1 if unavailable). */\r\n distance: number;\r\n};\r\n\r\n/** Payload for periodic Eddystone distance update events during monitoring. */\r\nexport type EddystoneDistanceEvent = {\r\n identifier: string;\r\n namespace: string;\r\n instance: string;\r\n distance: number;\r\n};\r\n\r\n/** Payload for Eddystone timeout events (beacon in range for configured duration). */\r\nexport type EddystoneTimeoutEvent = {\r\n identifier: string;\r\n namespace: string;\r\n instance: string;\r\n /** Current distance in metres at the time the timeout fired. */\r\n distance: number;\r\n};\r\n\r\n/** Module event map. */\r\nexport type ExpoBeaconModuleEvents = {\r\n onBeaconEnter: (params: BeaconRegionEvent) => void;\r\n onBeaconExit: (params: BeaconRegionEvent) => void;\r\n onBeaconDistance: (params: BeaconDistanceEvent) => void;\r\n /** Fired once after a paired beacon has been continuously in range for its configured `timeoutSeconds`. */\r\n onBeaconTimeout: (params: BeaconTimeoutEvent) => void;\r\n /** Fired continuously during a live scan as each iBeacon is detected. */\r\n onBeaconFound: (params: BeaconScanResult) => void;\r\n /** Fired continuously during a live scan as each Eddystone beacon is detected. */\r\n onEddystoneFound: (params: EddystoneScanResult) => void;\r\n onEddystoneEnter: (params: EddystoneRegionEvent) => void;\r\n onEddystoneExit: (params: EddystoneRegionEvent) => void;\r\n onEddystoneDistance: (params: EddystoneDistanceEvent) => void;\r\n /** Fired once after a paired Eddystone has been continuously in range for its configured `timeoutSeconds`. */\r\n onEddystoneTimeout: (params: EddystoneTimeoutEvent) => void;\r\n};\r\n\r\n/** Options for filtering event logs. */\r\nexport type EventLogQueryOptions = {\r\n /** Maximum number of log entries to return (default: 1000, max: 10000). */\r\n limit?: number;\r\n /** Filter by event type (e.g. \"onBeaconEnter\", \"onBeaconExit\"). */\r\n eventType?: string;\r\n /** Only return events with timestamp >= this value (ms since epoch). */\r\n sinceTimestamp?: number;\r\n};\r\n\r\n/** A single logged beacon event entry. */\r\nexport type EventLogEntry = {\r\n id: number;\r\n /** Timestamp in milliseconds since epoch. */\r\n timestamp: number;\r\n /** The event type that was logged (e.g. \"onBeaconEnter\"). */\r\n eventType: string;\r\n /** Beacon identifier, if available. */\r\n identifier?: string;\r\n /** The full event payload that was sent to JS. */\r\n data: Record<string, unknown>;\r\n};\r\n"]}
|
|
1
|
+
{"version":3,"file":"ExpoBeacon.types.js","sourceRoot":"","sources":["../src/ExpoBeacon.types.ts"],"names":[],"mappings":"","sourcesContent":["/** Raw beacon discovered during a scan. */\r\nexport type BeaconScanResult = {\r\n uuid: string; // iBeacon proximity UUID (uppercase, formatted)\r\n major: number; // iBeacon major value (0–65535)\r\n minor: number; // iBeacon minor value (0–65535)\r\n rssi: number; // Signal strength in dBm (negative number)\r\n distance: number; // Estimated distance in meters\r\n txPower: number; // Calibrated TX power\r\n /** BLE advertising device name. May be undefined on iOS (CoreLocation does not expose it for iBeacon). */\r\n name?: string;\r\n};\r\n\r\n/**\r\n * A beacon that has been paired/registered for monitoring.\r\n *\r\n * Note: Paired beacon data is stored unencrypted in UserDefaults (iOS) /\r\n * SharedPreferences (Android) and may be included in device backups.\r\n */\r\nexport type PairedBeacon = {\r\n identifier: string; // User-defined label (e.g. \"lobby-door\")\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n /** BLE advertising device name, if provided at pairing time. */\r\n name?: string;\r\n /**\r\n * Timeout in seconds. When set, the module fires `onBeaconTimeout` once\r\n * after the beacon has been continuously in range for this duration.\r\n * The timer resets if the beacon exits and re-enters range.\r\n */\r\n timeoutSeconds?: number;\r\n};\r\n\r\n/** Payload for enter/exit region events. */\r\nexport type BeaconRegionEvent = {\r\n identifier: string; // Matches PairedBeacon.identifier\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n event: \"enter\" | \"exit\";\r\n /** Measured distance in metres at the time of the event (–1 if unavailable). */\r\n distance: number;\r\n /** Signal strength in dBm at the time of the event (0 if unavailable). */\r\n rssi?: number;\r\n};\r\n\r\n/** Payload for periodic distance update events during monitoring. */\r\nexport type BeaconDistanceEvent = {\r\n identifier: string;\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n distance: number;\r\n /** Signal strength in dBm (0 if unavailable). */\r\n rssi?: number;\r\n};\r\n\r\n/** Payload for beacon timeout events (beacon in range for configured duration). */\r\nexport type BeaconTimeoutEvent = {\r\n identifier: string;\r\n uuid: string;\r\n major: number;\r\n minor: number;\r\n /** Current distance in metres at the time the timeout fired. */\r\n distance: number;\r\n};\r\n\r\n/** Configuration for beacon enter/exit event notifications. */\r\nexport type BeaconNotificationConfig = {\r\n /** Whether to show enter/exit notifications. Default: true. */\r\n enabled?: boolean;\r\n /** Notification title on beacon enter. Default: \"Beacon Entered\". */\r\n enterTitle?: string;\r\n /** Notification title on beacon exit. Default: \"Beacon Exited\". */\r\n exitTitle?: string;\r\n /**\r\n * Notification body template. Supports {identifier} and {event} placeholders.\r\n * Default: \"{identifier} region {event}ed\".\r\n */\r\n body?: string;\r\n /** Play a sound with the notification (iOS only). Default: true. */\r\n sound?: boolean;\r\n /** Android drawable resource name for the notification icon (e.g. \"ic_notification\"). */\r\n icon?: string;\r\n};\r\n\r\n/** Configuration for the Android foreground service notification (persistent status bar entry). */\r\nexport type ForegroundServiceConfig = {\r\n /** Title of the persistent notification. Default: \"Beacon Monitoring Active\". */\r\n title?: string;\r\n /** Body text of the persistent notification. Default: \"Monitoring for iBeacons in the background\". */\r\n text?: string;\r\n /** Android drawable resource name for the notification icon. */\r\n icon?: string;\r\n};\r\n\r\n/** Configuration for the Android notification channel. */\r\nexport type NotificationChannelConfig = {\r\n /** Channel display name shown in system settings. Default: \"Beacon Monitoring\". */\r\n name?: string;\r\n /** Channel description shown in system settings. Default: \"Used for background iBeacon region monitoring\". */\r\n description?: string;\r\n /**\r\n * Channel importance level. Default: 'low'.\r\n * Note: Android may ignore decreases in importance after first channel creation until the app is reinstalled.\r\n */\r\n importance?: \"low\" | \"default\" | \"high\";\r\n};\r\n\r\n/** Combined notification configuration for all notification types. */\r\nexport type NotificationConfig = {\r\n /** Settings for beacon enter/exit event notifications. */\r\n beaconEvents?: BeaconNotificationConfig;\r\n /** Settings for the persistent foreground service notification (Android only). */\r\n foregroundService?: ForegroundServiceConfig;\r\n /** Settings for the Android notification channel (Android only). */\r\n channel?: NotificationChannelConfig;\r\n};\r\n\r\n/** Options accepted by startMonitoring(). */\r\nexport type MonitoringOptions = {\r\n /**\r\n * Maximum distance in metres for distance-based enter events.\r\n * Exit events are always emitted when the region is lost.\r\n */\r\n maxDistance?: number;\r\n /**\r\n * Distance in metres at which exit events fire (must be ≥ maxDistance).\r\n * Creates a hysteresis band between enter and exit thresholds to prevent\r\n * rapid toggling near the boundary.\r\n *\r\n * Default when omitted: `maxDistance + min(maxDistance × 0.5, 2.5)`.\r\n * Only used when `maxDistance` is set.\r\n */\r\n exitDistance?: number;\r\n /**\r\n * Minimum RSSI (dBm) for a beacon reading to be considered valid.\r\n * Readings below this threshold are discarded as unreliable, preventing\r\n * false detections from reflected or distant signals.\r\n *\r\n * Default: -85. Typical range: -100 (very permissive) to -70 (strict).\r\n */\r\n minRssi?: number;\r\n /** Notification configuration overrides to apply for this monitoring session. */\r\n notifications?: NotificationConfig;\r\n};\r\n\r\n/** Eddystone frame type. */\r\nexport type EddystoneFrameType = \"uid\" | \"url\";\r\n\r\n/** Raw Eddystone beacon discovered during a scan. */\r\nexport type EddystoneScanResult = {\r\n frameType: EddystoneFrameType;\r\n /** 10-byte namespace ID as hex string (20 chars). Present for UID frames. */\r\n namespace?: string;\r\n /** 6-byte instance ID as hex string (12 chars). Present for UID frames. */\r\n instance?: string;\r\n /** Decoded URL. Present for URL frames. */\r\n url?: string;\r\n rssi: number;\r\n distance: number;\r\n txPower: number;\r\n /** BLE advertising device name. */\r\n name?: string;\r\n};\r\n\r\n/**\r\n * An Eddystone-UID beacon that has been paired/registered for monitoring.\r\n *\r\n * Note: Paired beacon data is stored unencrypted in UserDefaults (iOS) /\r\n * SharedPreferences (Android) and may be included in device backups.\r\n */\r\nexport type PairedEddystone = {\r\n identifier: string;\r\n /** 10-byte namespace ID as hex string (20 chars). */\r\n namespace: string;\r\n /** 6-byte instance ID as hex string (12 chars). */\r\n instance: string;\r\n /** BLE advertising device name, if provided at pairing time. */\r\n name?: string;\r\n /**\r\n * Timeout in seconds. When set, the module fires `onEddystoneTimeout` once\r\n * after the beacon has been continuously in range for this duration.\r\n * The timer resets if the beacon exits and re-enters range.\r\n */\r\n timeoutSeconds?: number;\r\n};\r\n\r\n/** Payload for Eddystone enter/exit region events. */\r\nexport type EddystoneRegionEvent = {\r\n identifier: string;\r\n namespace: string;\r\n instance: string;\r\n event: \"enter\" | \"exit\";\r\n /** Measured distance in metres at the time of the event (–1 if unavailable). */\r\n distance: number;\r\n /** Signal strength in dBm at the time of the event (0 if unavailable). */\r\n rssi?: number;\r\n};\r\n\r\n/** Payload for periodic Eddystone distance update events during monitoring. */\r\nexport type EddystoneDistanceEvent = {\r\n identifier: string;\r\n namespace: string;\r\n instance: string;\r\n distance: number;\r\n /** Signal strength in dBm (0 if unavailable). */\r\n rssi?: number;\r\n};\r\n\r\n/** Payload for Eddystone timeout events (beacon in range for configured duration). */\r\nexport type EddystoneTimeoutEvent = {\r\n identifier: string;\r\n namespace: string;\r\n instance: string;\r\n /** Current distance in metres at the time the timeout fired. */\r\n distance: number;\r\n};\r\n\r\n/** Module event map. */\r\nexport type ExpoBeaconModuleEvents = {\r\n onBeaconEnter: (params: BeaconRegionEvent) => void;\r\n onBeaconExit: (params: BeaconRegionEvent) => void;\r\n onBeaconDistance: (params: BeaconDistanceEvent) => void;\r\n /** Fired once after a paired beacon has been continuously in range for its configured `timeoutSeconds`. */\r\n onBeaconTimeout: (params: BeaconTimeoutEvent) => void;\r\n /** Fired continuously during a live scan as each iBeacon is detected. */\r\n onBeaconFound: (params: BeaconScanResult) => void;\r\n /** Fired continuously during a live scan as each Eddystone beacon is detected. */\r\n onEddystoneFound: (params: EddystoneScanResult) => void;\r\n onEddystoneEnter: (params: EddystoneRegionEvent) => void;\r\n onEddystoneExit: (params: EddystoneRegionEvent) => void;\r\n onEddystoneDistance: (params: EddystoneDistanceEvent) => void;\r\n /** Fired once after a paired Eddystone has been continuously in range for its configured `timeoutSeconds`. */\r\n onEddystoneTimeout: (params: EddystoneTimeoutEvent) => void;\r\n};\r\n\r\n/** Options for filtering event logs. */\r\nexport type EventLogQueryOptions = {\r\n /** Maximum number of log entries to return (default: 1000, max: 10000). */\r\n limit?: number;\r\n /** Filter by event type (e.g. \"onBeaconEnter\", \"onBeaconExit\"). */\r\n eventType?: string;\r\n /** Only return events with timestamp >= this value (ms since epoch). */\r\n sinceTimestamp?: number;\r\n};\r\n\r\n/** A single logged beacon event entry. */\r\nexport type EventLogEntry = {\r\n id: number;\r\n /** Timestamp in milliseconds since epoch. */\r\n timestamp: number;\r\n /** The event type that was logged (e.g. \"onBeaconEnter\"). */\r\n eventType: string;\r\n /** Beacon identifier, if available. */\r\n identifier?: string;\r\n /** The full event payload that was sent to JS. */\r\n data: Record<string, unknown>;\r\n};\r\n"]}
|
|
@@ -102,6 +102,15 @@ declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
|
|
|
102
102
|
clearEventLogs(): void;
|
|
103
103
|
/** Delete the entire event log database. Also disables logging. */
|
|
104
104
|
destroyEventLogs(): void;
|
|
105
|
+
/**
|
|
106
|
+
* Configure a remote API endpoint for native event forwarding.
|
|
107
|
+
* Once set, enter/exit/timeout events are POSTed directly from native code,
|
|
108
|
+
* ensuring delivery even when the JS bridge is not active (app backgrounded).
|
|
109
|
+
*
|
|
110
|
+
* @param url The API endpoint URL to POST events to.
|
|
111
|
+
* @param apiKey Optional API key sent as X-API-Key header.
|
|
112
|
+
*/
|
|
113
|
+
setApiEndpoint(url: string, apiKey?: string): void;
|
|
105
114
|
}
|
|
106
115
|
declare const _default: ExpoBeaconModule;
|
|
107
116
|
export default _default;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeaconModule.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EACL,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAE5B,OAAO,OAAO,gBAAiB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IACzE;;;;;;;;;;;OAWG;IACH,mBAAmB,CACjB,KAAK,CAAC,EAAE,MAAM,EAAE,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAE9B;;;;;OAKG;IACH,sBAAsB,CACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAEjC;;OAEG;IACH,UAAU,CACR,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEtC;;OAEG;IACH,gBAAgB,IAAI,YAAY,EAAE;IAElC;;OAEG;IACH,aAAa,CACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEzC;;OAEG;IACH,mBAAmB,IAAI,eAAe,EAAE;IAExC;;;OAGG;IACH,qBAAqB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAEvD;;;;;;;OAOG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAEpE;;OAEG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAE/B;;;OAGG;IACH,mBAAmB,IAAI,IAAI;IAE3B,iEAAiE;IACjE,kBAAkB,IAAI,IAAI;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI;IAElB,yEAAyE;IACzE,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC;IAE3C;;;OAGG;IACH,2BAA2B,IAAI,OAAO;IAEtC;;;;;OAKG;IACH,mCAAmC,IAAI,OAAO,CAAC,OAAO,CAAC;IAEvD,4FAA4F;IAC5F,kBAAkB,IAAI,IAAI;IAE1B,oEAAoE;IACpE,mBAAmB,IAAI,IAAI;IAE3B;;;OAGG;IACH,YAAY,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,EAAE;IAE7D,kDAAkD;IAClD,cAAc,IAAI,IAAI;IAEtB,mEAAmE;IACnE,gBAAgB,IAAI,IAAI;
|
|
1
|
+
{"version":3,"file":"ExpoBeaconModule.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EACL,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAE5B,OAAO,OAAO,gBAAiB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IACzE;;;;;;;;;;;OAWG;IACH,mBAAmB,CACjB,KAAK,CAAC,EAAE,MAAM,EAAE,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAE9B;;;;;OAKG;IACH,sBAAsB,CACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAEjC;;OAEG;IACH,UAAU,CACR,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEtC;;OAEG;IACH,gBAAgB,IAAI,YAAY,EAAE;IAElC;;OAEG;IACH,aAAa,CACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEzC;;OAEG;IACH,mBAAmB,IAAI,eAAe,EAAE;IAExC;;;OAGG;IACH,qBAAqB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAEvD;;;;;;;OAOG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAEpE;;OAEG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAE/B;;;OAGG;IACH,mBAAmB,IAAI,IAAI;IAE3B,iEAAiE;IACjE,kBAAkB,IAAI,IAAI;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI;IAElB,yEAAyE;IACzE,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC;IAE3C;;;OAGG;IACH,2BAA2B,IAAI,OAAO;IAEtC;;;;;OAKG;IACH,mCAAmC,IAAI,OAAO,CAAC,OAAO,CAAC;IAEvD,4FAA4F;IAC5F,kBAAkB,IAAI,IAAI;IAE1B,oEAAoE;IACpE,mBAAmB,IAAI,IAAI;IAE3B;;;OAGG;IACH,YAAY,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,EAAE;IAE7D,kDAAkD;IAClD,cAAc,IAAI,IAAI;IAEtB,mEAAmE;IACnE,gBAAgB,IAAI,IAAI;IAExB;;;;;;;OAOG;IACH,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;CACnD;;AAED,wBAAmE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoBeaconModule.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"ExpoBeaconModule.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAuKzD,eAAe,mBAAmB,CAAmB,YAAY,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\r\n\r\nimport {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n NotificationConfig,\r\n MonitoringOptions,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\ndeclare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {\r\n /**\r\n * Start a one-shot iBeacon scan. Resolves with discovered beacons after scanDuration ms.\r\n *\r\n * Pass one or more UUIDs to scan for specific beacons (uses CoreLocation on iOS).\r\n * On iOS, at least one UUID is required — Apple strips iBeacon data from BLE\r\n * advertisements, making wildcard discovery impossible. When you pass an empty\r\n * array, the module automatically uses UUIDs from paired beacons.\r\n * On Android, pass an empty array to discover all nearby iBeacons.\r\n *\r\n * @param uuids Proximity UUIDs to filter by. Empty/omitted = use paired UUIDs (iOS) or wildcard (Android).\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForBeaconsAsync(\r\n uuids?: string[],\r\n scanDuration?: number,\r\n ): Promise<BeaconScanResult[]>;\r\n\r\n /**\r\n * Start a one-shot Eddystone beacon scan using BLE.\r\n * Discovers Eddystone-UID and Eddystone-URL frames.\r\n *\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForEddystonesAsync(\r\n scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]>;\r\n\r\n /**\r\n * Register a beacon for persistent region monitoring.\r\n */\r\n pairBeacon(\r\n identifier: string,\r\n uuid: string,\r\n major: number,\r\n minor: number,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired beacon.\r\n */\r\n unpairBeacon(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired beacons.\r\n */\r\n getPairedBeacons(): PairedBeacon[];\r\n\r\n /**\r\n * Register an Eddystone-UID beacon for persistent monitoring.\r\n */\r\n pairEddystone(\r\n identifier: string,\r\n namespace: string,\r\n instance: string,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired Eddystone beacon.\r\n */\r\n unpairEddystone(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired Eddystone beacons.\r\n */\r\n getPairedEddystones(): PairedEddystone[];\r\n\r\n /**\r\n * Set persistent notification configuration. Settings are saved and applied to all\r\n * subsequent monitoring sessions until explicitly changed.\r\n */\r\n setNotificationConfig(config: NotificationConfig): void;\r\n\r\n /**\r\n * Start background region monitoring for all paired beacons.\r\n * On Android starts a foreground service.\r\n * On iOS starts CLLocationManager region monitoring.\r\n *\r\n * Accepts a plain number (backward-compatible maxDistance shorthand) or a\r\n * MonitoringOptions object with maxDistance and/or notification overrides.\r\n */\r\n startMonitoring(options?: MonitoringOptions | number): Promise<void>;\r\n\r\n /**\r\n * Stop background region monitoring.\r\n */\r\n stopMonitoring(): Promise<void>;\r\n\r\n /**\r\n * Start a continuous BLE scan. Fires `onBeaconFound` events as beacons are detected.\r\n * Call stopContinuousScan() to end the scan.\r\n */\r\n startContinuousScan(): void;\r\n\r\n /** Stop the continuous scan started by startContinuousScan(). */\r\n stopContinuousScan(): void;\r\n\r\n /**\r\n * Cancel any in-progress one-shot scan (iBeacon or Eddystone).\r\n * The pending promise will be rejected with code \"SCAN_CANCELLED\".\r\n */\r\n cancelScan(): void;\r\n\r\n /** Request Bluetooth + Location permissions. Returns true if granted. */\r\n requestPermissionsAsync(): Promise<boolean>;\r\n\r\n /**\r\n * Check whether the app is exempt from Android battery optimizations.\r\n * Always returns true on iOS and web (no equivalent concept).\r\n */\r\n isBatteryOptimizationExempt(): boolean;\r\n\r\n /**\r\n * Request exemption from Android battery optimizations.\r\n * Opens the system dialog asking the user to whitelist this app.\r\n * Returns true if the dialog was shown (or already exempt), false on failure.\r\n * Always resolves true on iOS and web.\r\n */\r\n requestBatteryOptimizationExemption(): Promise<boolean>;\r\n\r\n /** Enable SQLite event logging. All beacon events will be persisted to a local database. */\r\n enableEventLogging(): void;\r\n\r\n /** Disable event logging. Previously logged events are retained. */\r\n disableEventLogging(): void;\r\n\r\n /**\r\n * Retrieve logged beacon events from the SQLite database.\r\n * @param options Optional filters (limit, eventType, sinceTimestamp).\r\n */\r\n getEventLogs(options?: EventLogQueryOptions): EventLogEntry[];\r\n\r\n /** Delete all logged events from the database. */\r\n clearEventLogs(): void;\r\n\r\n /** Delete the entire event log database. Also disables logging. */\r\n destroyEventLogs(): void;\r\n\r\n /**\r\n * Configure a remote API endpoint for native event forwarding.\r\n * Once set, enter/exit/timeout events are POSTed directly from native code,\r\n * ensuring delivery even when the JS bridge is not active (app backgrounded).\r\n *\r\n * @param url The API endpoint URL to POST events to.\r\n * @param apiKey Optional API key sent as X-API-Key header.\r\n */\r\n setApiEndpoint(url: string, apiKey?: string): void;\r\n}\r\n\r\nexport default requireNativeModule<ExpoBeaconModule>(\"ExpoBeacon\");\r\n"]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os.log
|
|
3
|
+
|
|
4
|
+
private let API_URL_KEY = "expo.beacon.api_url"
|
|
5
|
+
private let API_KEY_KEY = "expo.beacon.api_key"
|
|
6
|
+
private let MAX_RETRIES = 3
|
|
7
|
+
|
|
8
|
+
/// Fire-and-forget HTTP event forwarder for beacon events.
|
|
9
|
+
/// Sends enter/exit/timeout events to a configured API endpoint from native code,
|
|
10
|
+
/// ensuring delivery even when the JS bridge is not active (app backgrounded).
|
|
11
|
+
final class BeaconApiForwarder {
|
|
12
|
+
|
|
13
|
+
private let defaults: UserDefaults
|
|
14
|
+
private let session: URLSession
|
|
15
|
+
|
|
16
|
+
var isConfigured: Bool {
|
|
17
|
+
guard let url = defaults.string(forKey: API_URL_KEY) else { return false }
|
|
18
|
+
return !url.isEmpty
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
init(defaults: UserDefaults? = nil) {
|
|
22
|
+
self.defaults = defaults ?? (UserDefaults(suiteName: "expo.modules.beacon") ?? .standard)
|
|
23
|
+
let config = URLSessionConfiguration.default
|
|
24
|
+
config.timeoutIntervalForRequest = 10
|
|
25
|
+
config.timeoutIntervalForResource = 30
|
|
26
|
+
// Use background-safe waitsForConnectivity so requests survive brief connectivity gaps
|
|
27
|
+
config.waitsForConnectivity = true
|
|
28
|
+
self.session = URLSession(configuration: config)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func configure(url: String, apiKey: String?) {
|
|
32
|
+
defaults.set(url, forKey: API_URL_KEY)
|
|
33
|
+
if let key = apiKey {
|
|
34
|
+
defaults.set(key, forKey: API_KEY_KEY)
|
|
35
|
+
} else {
|
|
36
|
+
defaults.removeObject(forKey: API_KEY_KEY)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func getConfig() -> [String: String?] {
|
|
41
|
+
return [
|
|
42
|
+
"url": defaults.string(forKey: API_URL_KEY),
|
|
43
|
+
"apiKey": defaults.string(forKey: API_KEY_KEY)
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Send a beacon event to the configured API endpoint.
|
|
48
|
+
/// Fire-and-forget with simple retry (3 attempts, exponential backoff).
|
|
49
|
+
/// No-op if no endpoint is configured.
|
|
50
|
+
func forwardEvent(_ params: [String: Any]) {
|
|
51
|
+
guard let urlString = defaults.string(forKey: API_URL_KEY),
|
|
52
|
+
!urlString.isEmpty,
|
|
53
|
+
let url = URL(string: urlString) else { return }
|
|
54
|
+
|
|
55
|
+
let apiKey = defaults.string(forKey: API_KEY_KEY)
|
|
56
|
+
|
|
57
|
+
var payload = params
|
|
58
|
+
payload["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
|
|
59
|
+
payload["platform"] = "ios"
|
|
60
|
+
payload["sdkVersion"] = ProcessInfo.processInfo.operatingSystemVersion.majorVersion
|
|
61
|
+
|
|
62
|
+
guard let body = try? JSONSerialization.data(withJSONObject: payload) else {
|
|
63
|
+
os_log(.error, "BeaconApiForwarder: failed to serialize payload")
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
sendWithRetry(url: url, body: body, apiKey: apiKey, attempt: 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private func sendWithRetry(url: URL, body: Data, apiKey: String?, attempt: Int) {
|
|
71
|
+
var request = URLRequest(url: url)
|
|
72
|
+
request.httpMethod = "POST"
|
|
73
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
74
|
+
if let key = apiKey {
|
|
75
|
+
request.setValue(key, forHTTPHeaderField: "X-API-Key")
|
|
76
|
+
}
|
|
77
|
+
request.httpBody = body
|
|
78
|
+
|
|
79
|
+
let task = session.dataTask(with: request) { [weak self] _, response, error in
|
|
80
|
+
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
81
|
+
|
|
82
|
+
if statusCode >= 200 && statusCode < 300 {
|
|
83
|
+
return // Success
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4xx client errors — don't retry
|
|
87
|
+
if statusCode >= 400 && statusCode < 500 {
|
|
88
|
+
os_log(.error, "BeaconApiForwarder: HTTP %{public}d — not retrying", statusCode)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if attempt < MAX_RETRIES {
|
|
93
|
+
let delay = pow(2.0, Double(attempt - 1)) // 1s, 2s, 4s
|
|
94
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) {
|
|
95
|
+
self?.sendWithRetry(url: url, body: body, apiKey: apiKey, attempt: attempt + 1)
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
let msg = error?.localizedDescription ?? "HTTP \(statusCode)"
|
|
99
|
+
os_log(.error, "BeaconApiForwarder: failed after %d attempts: %{public}@", MAX_RETRIES, msg)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
task.resume()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -11,12 +11,17 @@ private let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
|
|
|
11
11
|
private let EXIT_DISTANCE_KEY = "expo.beacon.exit_distance"
|
|
12
12
|
private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
|
|
13
13
|
private let EVENT_LOGGING_ENABLED_KEY = "expo.beacon.event_logging_enabled"
|
|
14
|
+
private let MIN_RSSI_KEY = "expo.beacon.min_rssi"
|
|
15
|
+
|
|
16
|
+
/// Default minimum RSSI (dBm) below which beacon readings are discarded as unreliable.
|
|
17
|
+
private let DEFAULT_MIN_RSSI: Int = -85
|
|
14
18
|
|
|
15
19
|
/// Number of consecutive ranging misses before emitting a distance-based exit event.
|
|
16
|
-
/// With ~1 s CoreLocation ranging cycles
|
|
17
|
-
/// declaring exit — tolerant of
|
|
18
|
-
///
|
|
19
|
-
|
|
20
|
+
/// With ~1 s CoreLocation ranging cycles (iBeacon) or ~2 s Eddystone monitoring ticks,
|
|
21
|
+
/// 20 misses ≈ 20–40 s of silence before declaring exit — tolerant of iOS background
|
|
22
|
+
/// BLE throttling while still responsive to actual departures.
|
|
23
|
+
/// NOTE: Android uses 10 with a ~2.1 s scan cycle (≈21 s effective).
|
|
24
|
+
private let EXIT_MISS_THRESHOLD = 20
|
|
20
25
|
/// Number of consecutive readings required to confirm a distance-based enter or exit transition.
|
|
21
26
|
/// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
|
|
22
27
|
private let HYSTERESIS_COUNT = 3
|
|
@@ -24,8 +29,9 @@ private let HYSTERESIS_COUNT = 3
|
|
|
24
29
|
/// Eddystone monitoring timer interval in seconds.
|
|
25
30
|
private let EDDYSTONE_MONITORING_TICK_INTERVAL: TimeInterval = 2.0
|
|
26
31
|
/// Maximum age (in seconds) before a beacon is considered "not recently seen".
|
|
27
|
-
/// Set
|
|
28
|
-
|
|
32
|
+
/// Set high enough to tolerate iOS background CoreBluetooth throttling which
|
|
33
|
+
/// can cause 10-12 s gaps between Eddystone advertisements.
|
|
34
|
+
private let EDDYSTONE_RECENTLY_SEEN_THRESHOLD: TimeInterval = 15.0
|
|
29
35
|
/// Minimum interval between consecutive distance event emissions per identifier.
|
|
30
36
|
private let DISTANCE_EVENT_THROTTLE_INTERVAL: TimeInterval = 1.0
|
|
31
37
|
|
|
@@ -89,6 +95,16 @@ public class ExpoBeaconModule: Module {
|
|
|
89
95
|
private var eddystoneExitCounters: [String: Int] = [:]
|
|
90
96
|
private var eddystoneLastDistanceEmit: [String: Date] = [:]
|
|
91
97
|
|
|
98
|
+
/// Minimum RSSI threshold — readings below this are treated as unreliable.
|
|
99
|
+
private var minRssiThreshold: Int = DEFAULT_MIN_RSSI
|
|
100
|
+
|
|
101
|
+
/// Distance smoothing (EMA) state per identifier.
|
|
102
|
+
private var smoothedDistances: [String: Double] = [:]
|
|
103
|
+
/// EMA weight for new readings. 0.4 balances responsiveness vs noise rejection.
|
|
104
|
+
private static let DISTANCE_EMA_ALPHA = 0.4
|
|
105
|
+
/// If raw distance differs from smoothed by more than this factor, treat as outlier.
|
|
106
|
+
private static let DISTANCE_JUMP_FACTOR = 5.0
|
|
107
|
+
|
|
92
108
|
// Permission callback
|
|
93
109
|
private var permissionCompletion: ((Bool) -> Void)?
|
|
94
110
|
|
|
@@ -100,6 +116,9 @@ public class ExpoBeaconModule: Module {
|
|
|
100
116
|
private var eventLogger: BeaconEventLogger?
|
|
101
117
|
private var loggingEnabled = false
|
|
102
118
|
|
|
119
|
+
// Native API forwarder (fire-and-forget HTTP)
|
|
120
|
+
private lazy var apiForwarder = BeaconApiForwarder(defaults: defaults)
|
|
121
|
+
|
|
103
122
|
// Timeout timers — fire once after beacon stays in range for configured duration
|
|
104
123
|
private var beaconTimeoutTimers: [String: DispatchWorkItem] = [:]
|
|
105
124
|
private var eddystoneTimeoutTimers: [String: DispatchWorkItem] = [:]
|
|
@@ -305,11 +324,13 @@ public class ExpoBeaconModule: Module {
|
|
|
305
324
|
AsyncFunction("startMonitoring") { (options: Either<Double, [String: Any]>?, promise: Promise) in
|
|
306
325
|
var maxDistance: Double? = nil
|
|
307
326
|
var exitDistance: Double? = nil
|
|
327
|
+
var minRssi: Int? = nil
|
|
308
328
|
if let dist: Double = options?.get() {
|
|
309
329
|
maxDistance = dist
|
|
310
330
|
} else if let map: [String: Any] = options?.get() {
|
|
311
331
|
maxDistance = map["maxDistance"] as? Double
|
|
312
332
|
exitDistance = map["exitDistance"] as? Double
|
|
333
|
+
minRssi = map["minRssi"] as? Int
|
|
313
334
|
if let notifications = map["notifications"] as? [String: Any],
|
|
314
335
|
let data = try? JSONSerialization.data(withJSONObject: notifications),
|
|
315
336
|
let json = String(data: data, encoding: .utf8) {
|
|
@@ -342,6 +363,13 @@ public class ExpoBeaconModule: Module {
|
|
|
342
363
|
} else {
|
|
343
364
|
self.defaults.removeObject(forKey: EXIT_DISTANCE_KEY)
|
|
344
365
|
}
|
|
366
|
+
if let rssi = minRssi {
|
|
367
|
+
self.defaults.set(rssi, forKey: MIN_RSSI_KEY)
|
|
368
|
+
self.minRssiThreshold = rssi
|
|
369
|
+
} else {
|
|
370
|
+
self.defaults.removeObject(forKey: MIN_RSSI_KEY)
|
|
371
|
+
self.minRssiThreshold = DEFAULT_MIN_RSSI
|
|
372
|
+
}
|
|
345
373
|
self.defaults.set(true, forKey: IS_MONITORING_KEY)
|
|
346
374
|
self.requestLocationPermission { granted in
|
|
347
375
|
guard granted else {
|
|
@@ -454,6 +482,12 @@ public class ExpoBeaconModule: Module {
|
|
|
454
482
|
self.eventLogger = nil
|
|
455
483
|
}
|
|
456
484
|
|
|
485
|
+
// MARK: - API Forwarding
|
|
486
|
+
|
|
487
|
+
Function("setApiEndpoint") { (url: String, apiKey: String?) -> Void in
|
|
488
|
+
self.apiForwarder.configure(url: url, apiKey: apiKey)
|
|
489
|
+
}
|
|
490
|
+
|
|
457
491
|
// MARK: - Battery Optimization (Android-only; no-op on iOS)
|
|
458
492
|
|
|
459
493
|
Function("isBatteryOptimizationExempt") { () -> Bool in
|
|
@@ -537,6 +571,10 @@ public class ExpoBeaconModule: Module {
|
|
|
537
571
|
private func startRegionMonitoring() {
|
|
538
572
|
stopRegionMonitoring()
|
|
539
573
|
|
|
574
|
+
// Restore persisted min RSSI threshold (survives app restarts)
|
|
575
|
+
let storedRssi = defaults.object(forKey: MIN_RSSI_KEY) as? Int
|
|
576
|
+
minRssiThreshold = storedRssi ?? DEFAULT_MIN_RSSI
|
|
577
|
+
|
|
540
578
|
let beacons = loadPairedBeaconsRaw()
|
|
541
579
|
|
|
542
580
|
// CLLocationManager supports a maximum of 20 monitored regions.
|
|
@@ -599,6 +637,7 @@ public class ExpoBeaconModule: Module {
|
|
|
599
637
|
missCounters.removeAll()
|
|
600
638
|
enterCounters.removeAll()
|
|
601
639
|
exitCounters.removeAll()
|
|
640
|
+
smoothedDistances.removeAll()
|
|
602
641
|
|
|
603
642
|
for timer in beaconTimeoutTimers.values { timer.cancel() }
|
|
604
643
|
beaconTimeoutTimers.removeAll()
|
|
@@ -738,6 +777,10 @@ public class ExpoBeaconModule: Module {
|
|
|
738
777
|
|
|
739
778
|
guard let beacon = Self.parseEddystoneFrame(data: data, rssi: rssi.intValue) else { return }
|
|
740
779
|
|
|
780
|
+
// Discard weak signals that produce unreliable distance estimates
|
|
781
|
+
let beaconRssi = rssi.intValue
|
|
782
|
+
guard beaconRssi >= minRssiThreshold else { return }
|
|
783
|
+
|
|
741
784
|
// Augment with the BLE advertising device name if present
|
|
742
785
|
var beaconInfo = beacon
|
|
743
786
|
if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
|
|
@@ -775,9 +818,19 @@ public class ExpoBeaconModule: Module {
|
|
|
775
818
|
let exitDist = self.defaults.object(forKey: EXIT_DISTANCE_KEY) as? Double
|
|
776
819
|
let hasValidDistance = distance.isFinite && distance >= 0
|
|
777
820
|
if hasValidDistance || maxDist == nil {
|
|
821
|
+
// Apply EMA smoothing; jump guard returns nil for outliers
|
|
822
|
+
let effectiveDistance: Double
|
|
823
|
+
if hasValidDistance, let smoothed = smoothDistance(identifier: identifier, rawDistance: distance) {
|
|
824
|
+
effectiveDistance = smoothed
|
|
825
|
+
} else if hasValidDistance {
|
|
826
|
+
// Jump outlier — skip this cycle without resetting counters
|
|
827
|
+
break
|
|
828
|
+
} else {
|
|
829
|
+
effectiveDistance = distance
|
|
830
|
+
}
|
|
778
831
|
let action = evaluateDistanceHysteresis(
|
|
779
832
|
identifier: identifier,
|
|
780
|
-
distance:
|
|
833
|
+
distance: effectiveDistance,
|
|
781
834
|
maxDistance: maxDist,
|
|
782
835
|
exitDistance: exitDist,
|
|
783
836
|
entered: &eddystoneEnteredRegions,
|
|
@@ -791,7 +844,8 @@ public class ExpoBeaconModule: Module {
|
|
|
791
844
|
"namespace": ns,
|
|
792
845
|
"instance": inst,
|
|
793
846
|
"event": "enter",
|
|
794
|
-
"distance": distance
|
|
847
|
+
"distance": distance,
|
|
848
|
+
"rssi": beaconRssi
|
|
795
849
|
])
|
|
796
850
|
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
797
851
|
scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
|
|
@@ -802,7 +856,8 @@ public class ExpoBeaconModule: Module {
|
|
|
802
856
|
"namespace": ns,
|
|
803
857
|
"instance": inst,
|
|
804
858
|
"event": "exit",
|
|
805
|
-
"distance": distance
|
|
859
|
+
"distance": distance,
|
|
860
|
+
"rssi": beaconRssi
|
|
806
861
|
])
|
|
807
862
|
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
808
863
|
case .none:
|
|
@@ -825,7 +880,8 @@ public class ExpoBeaconModule: Module {
|
|
|
825
880
|
"identifier": identifier,
|
|
826
881
|
"namespace": ns,
|
|
827
882
|
"instance": inst,
|
|
828
|
-
"distance": distance
|
|
883
|
+
"distance": distance,
|
|
884
|
+
"rssi": beaconRssi
|
|
829
885
|
])
|
|
830
886
|
break
|
|
831
887
|
}
|
|
@@ -901,6 +957,10 @@ public class ExpoBeaconModule: Module {
|
|
|
901
957
|
let identifier = params["identifier"] as? String
|
|
902
958
|
getOrCreateEventLogger().logEvent(eventType: eventName, identifier: identifier, data: params)
|
|
903
959
|
}
|
|
960
|
+
// Forward enter/exit/timeout events to remote API (skip distance — too frequent)
|
|
961
|
+
if !eventName.lowercased().contains("distance") {
|
|
962
|
+
apiForwarder.forwardEvent(params)
|
|
963
|
+
}
|
|
904
964
|
sendEvent(eventName, params)
|
|
905
965
|
}
|
|
906
966
|
|
|
@@ -980,6 +1040,8 @@ public class ExpoBeaconModule: Module {
|
|
|
980
1040
|
eddystoneEnterCounters.removeAll()
|
|
981
1041
|
eddystoneExitCounters.removeAll()
|
|
982
1042
|
eddystoneLastDistanceEmit.removeAll()
|
|
1043
|
+
// Eddystone smoothed distances are in the shared smoothedDistances map;
|
|
1044
|
+
// they are cleaned up when stopRegionMonitoring clears the entire map.
|
|
983
1045
|
|
|
984
1046
|
for timer in eddystoneTimeoutTimers.values { timer.cancel() }
|
|
985
1047
|
eddystoneTimeoutTimers.removeAll()
|
|
@@ -1001,9 +1063,9 @@ public class ExpoBeaconModule: Module {
|
|
|
1001
1063
|
continue
|
|
1002
1064
|
}
|
|
1003
1065
|
|
|
1004
|
-
// Not seen recently —
|
|
1005
|
-
//
|
|
1006
|
-
|
|
1066
|
+
// Not seen recently — reset exit counter (miss counter handles exit
|
|
1067
|
+
// separately) but preserve enter counter so that background BLE
|
|
1068
|
+
// throttling gaps don't force re-accumulating HYSTERESIS_COUNT reads.
|
|
1007
1069
|
eddystoneExitCounters[identifier] = 0
|
|
1008
1070
|
guard eddystoneEnteredRegions.contains(identifier) else { continue }
|
|
1009
1071
|
|
|
@@ -1130,7 +1192,24 @@ public class ExpoBeaconModule: Module {
|
|
|
1130
1192
|
return dict
|
|
1131
1193
|
}
|
|
1132
1194
|
|
|
1133
|
-
// MARK: - Distance
|
|
1195
|
+
// MARK: - Distance smoothing + enter/exit hysteresis
|
|
1196
|
+
|
|
1197
|
+
/// Apply exponential moving average (EMA) smoothing to a raw distance reading.
|
|
1198
|
+
/// Returns nil if the reading is a jump outlier (raw differs from smoothed by > DISTANCE_JUMP_FACTOR).
|
|
1199
|
+
private func smoothDistance(identifier: String, rawDistance: Double) -> Double? {
|
|
1200
|
+
guard let prev = smoothedDistances[identifier] else {
|
|
1201
|
+
smoothedDistances[identifier] = rawDistance
|
|
1202
|
+
return rawDistance
|
|
1203
|
+
}
|
|
1204
|
+
// Jump guard: if the raw value is wildly different, treat as outlier
|
|
1205
|
+
let ratio = prev > 0.001 ? rawDistance / prev : rawDistance
|
|
1206
|
+
if ratio > Self.DISTANCE_JUMP_FACTOR || (ratio > 0 && ratio < 1.0 / Self.DISTANCE_JUMP_FACTOR) {
|
|
1207
|
+
return nil
|
|
1208
|
+
}
|
|
1209
|
+
let smoothed = Self.DISTANCE_EMA_ALPHA * rawDistance + (1 - Self.DISTANCE_EMA_ALPHA) * prev
|
|
1210
|
+
smoothedDistances[identifier] = smoothed
|
|
1211
|
+
return smoothed
|
|
1212
|
+
}
|
|
1134
1213
|
|
|
1135
1214
|
private enum HysteresisAction {
|
|
1136
1215
|
case none, enter, exit
|
|
@@ -1204,7 +1283,8 @@ public class ExpoBeaconModule: Module {
|
|
|
1204
1283
|
"uuid": (beacon?.uuid ?? region?.uuid)?.uuidString.uppercased() ?? "",
|
|
1205
1284
|
"major": beacon?.major.intValue ?? region?.major?.intValue ?? 0,
|
|
1206
1285
|
"minor": beacon?.minor.intValue ?? region?.minor?.intValue ?? 0,
|
|
1207
|
-
"distance": beacon != nil ? beacon!.accuracy : distance
|
|
1286
|
+
"distance": beacon != nil ? beacon!.accuracy : distance,
|
|
1287
|
+
"rssi": beacon?.rssi ?? 0
|
|
1208
1288
|
]
|
|
1209
1289
|
if let event = event {
|
|
1210
1290
|
params["event"] = event
|
|
@@ -1237,7 +1317,7 @@ public class ExpoBeaconModule: Module {
|
|
|
1237
1317
|
|
|
1238
1318
|
// 2. Distance-ranging for monitored beacons
|
|
1239
1319
|
if let (identifier, _) = distanceRangingConstraints.first(where: { $0.value == constraint }) {
|
|
1240
|
-
let validBeacon = beacons.first(where: { $0.accuracy >= 0 })
|
|
1320
|
+
let validBeacon = beacons.first(where: { $0.accuracy >= 0 && $0.rssi >= minRssiThreshold })
|
|
1241
1321
|
|
|
1242
1322
|
if let beacon = validBeacon {
|
|
1243
1323
|
// Got a valid reading — reset miss counter
|
|
@@ -1252,9 +1332,16 @@ public class ExpoBeaconModule: Module {
|
|
|
1252
1332
|
// (HYSTERESIS_COUNT consecutive readings to confirm enter).
|
|
1253
1333
|
let maxDist = self.defaults.object(forKey: MAX_DISTANCE_KEY) as? Double
|
|
1254
1334
|
let exitDist = self.defaults.object(forKey: EXIT_DISTANCE_KEY) as? Double
|
|
1335
|
+
|
|
1336
|
+
// Apply EMA smoothing; jump guard returns nil for outliers
|
|
1337
|
+
guard let smoothed = smoothDistance(identifier: identifier, rawDistance: beacon.accuracy) else {
|
|
1338
|
+
// Jump outlier — skip this cycle without resetting counters
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1255
1342
|
let action = evaluateDistanceHysteresis(
|
|
1256
1343
|
identifier: identifier,
|
|
1257
|
-
distance:
|
|
1344
|
+
distance: smoothed,
|
|
1258
1345
|
maxDistance: maxDist,
|
|
1259
1346
|
exitDistance: exitDist,
|
|
1260
1347
|
entered: &enteredRegions,
|
|
@@ -1278,8 +1365,9 @@ public class ExpoBeaconModule: Module {
|
|
|
1278
1365
|
// UUID-only constraints in check 3 below, not here, to avoid
|
|
1279
1366
|
// duplicate events when both monitoring and continuous scan are active.
|
|
1280
1367
|
} else {
|
|
1281
|
-
// No valid beacon reading — beacon may have disappeared
|
|
1282
|
-
|
|
1368
|
+
// No valid beacon reading — beacon may have disappeared.
|
|
1369
|
+
// Preserve enter counter so background accuracy=-1 gaps don't
|
|
1370
|
+
// force re-accumulating HYSTERESIS_COUNT reads from scratch.
|
|
1283
1371
|
exitCounters[identifier] = 0
|
|
1284
1372
|
let count = (missCounters[identifier] ?? 0) + 1
|
|
1285
1373
|
missCounters[identifier] = count
|
package/package.json
CHANGED
package/src/ExpoBeacon.types.ts
CHANGED
|
@@ -40,6 +40,8 @@ export type BeaconRegionEvent = {
|
|
|
40
40
|
event: "enter" | "exit";
|
|
41
41
|
/** Measured distance in metres at the time of the event (–1 if unavailable). */
|
|
42
42
|
distance: number;
|
|
43
|
+
/** Signal strength in dBm at the time of the event (0 if unavailable). */
|
|
44
|
+
rssi?: number;
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
/** Payload for periodic distance update events during monitoring. */
|
|
@@ -49,6 +51,8 @@ export type BeaconDistanceEvent = {
|
|
|
49
51
|
major: number;
|
|
50
52
|
minor: number;
|
|
51
53
|
distance: number;
|
|
54
|
+
/** Signal strength in dBm (0 if unavailable). */
|
|
55
|
+
rssi?: number;
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
/** Payload for beacon timeout events (beacon in range for configured duration). */
|
|
@@ -129,6 +133,14 @@ export type MonitoringOptions = {
|
|
|
129
133
|
* Only used when `maxDistance` is set.
|
|
130
134
|
*/
|
|
131
135
|
exitDistance?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Minimum RSSI (dBm) for a beacon reading to be considered valid.
|
|
138
|
+
* Readings below this threshold are discarded as unreliable, preventing
|
|
139
|
+
* false detections from reflected or distant signals.
|
|
140
|
+
*
|
|
141
|
+
* Default: -85. Typical range: -100 (very permissive) to -70 (strict).
|
|
142
|
+
*/
|
|
143
|
+
minRssi?: number;
|
|
132
144
|
/** Notification configuration overrides to apply for this monitoring session. */
|
|
133
145
|
notifications?: NotificationConfig;
|
|
134
146
|
};
|
|
@@ -182,6 +194,8 @@ export type EddystoneRegionEvent = {
|
|
|
182
194
|
event: "enter" | "exit";
|
|
183
195
|
/** Measured distance in metres at the time of the event (–1 if unavailable). */
|
|
184
196
|
distance: number;
|
|
197
|
+
/** Signal strength in dBm at the time of the event (0 if unavailable). */
|
|
198
|
+
rssi?: number;
|
|
185
199
|
};
|
|
186
200
|
|
|
187
201
|
/** Payload for periodic Eddystone distance update events during monitoring. */
|
|
@@ -190,6 +204,8 @@ export type EddystoneDistanceEvent = {
|
|
|
190
204
|
namespace: string;
|
|
191
205
|
instance: string;
|
|
192
206
|
distance: number;
|
|
207
|
+
/** Signal strength in dBm (0 if unavailable). */
|
|
208
|
+
rssi?: number;
|
|
193
209
|
};
|
|
194
210
|
|
|
195
211
|
/** Payload for Eddystone timeout events (beacon in range for configured duration). */
|
package/src/ExpoBeaconModule.ts
CHANGED
|
@@ -153,6 +153,16 @@ declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
|
|
|
153
153
|
|
|
154
154
|
/** Delete the entire event log database. Also disables logging. */
|
|
155
155
|
destroyEventLogs(): void;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Configure a remote API endpoint for native event forwarding.
|
|
159
|
+
* Once set, enter/exit/timeout events are POSTed directly from native code,
|
|
160
|
+
* ensuring delivery even when the JS bridge is not active (app backgrounded).
|
|
161
|
+
*
|
|
162
|
+
* @param url The API endpoint URL to POST events to.
|
|
163
|
+
* @param apiKey Optional API key sent as X-API-Key header.
|
|
164
|
+
*/
|
|
165
|
+
setApiEndpoint(url: string, apiKey?: string): void;
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
export default requireNativeModule<ExpoBeaconModule>("ExpoBeacon");
|