expo-beacon 0.6.3 → 0.6.4
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/BeaconConstants.kt +9 -0
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +69 -47
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +16 -0
- package/ios/ExpoBeaconModule.swift +53 -32
- package/package.json +1 -1
|
@@ -13,6 +13,15 @@ internal const val MONITORING_OPTIONS_PREFS = "expo.beacon.monitoring_options"
|
|
|
13
13
|
internal const val EVENT_LOGGING_PREFS = "expo.beacon.event_logging"
|
|
14
14
|
internal const val EVENT_LOGGING_ENABLED_KEY = "enabled"
|
|
15
15
|
|
|
16
|
+
/** Foreground-service scan window for background monitoring responsiveness. */
|
|
17
|
+
internal const val MONITORING_SCAN_PERIOD_MS = 1100L
|
|
18
|
+
|
|
19
|
+
/** Gap between scan windows while the foreground service is active. */
|
|
20
|
+
internal const val MONITORING_BETWEEN_SCAN_PERIOD_MS = 0L
|
|
21
|
+
|
|
22
|
+
/** Ignore monitor-based exits if ranging saw the beacon within this window. */
|
|
23
|
+
internal const val RECENT_RANGING_SIGHTING_GRACE_MS = 4000L
|
|
24
|
+
|
|
16
25
|
/** Number of consecutive ranging misses before emitting a distance-based exit event. */
|
|
17
26
|
internal const val EXIT_MISS_THRESHOLD = 3
|
|
18
27
|
|
|
@@ -10,6 +10,7 @@ import android.os.Handler
|
|
|
10
10
|
import android.os.IBinder
|
|
11
11
|
import android.os.Looper
|
|
12
12
|
import android.os.RemoteException
|
|
13
|
+
import android.os.SystemClock
|
|
13
14
|
import android.util.Log
|
|
14
15
|
import java.util.concurrent.atomic.AtomicInteger
|
|
15
16
|
import androidx.core.app.NotificationCompat
|
|
@@ -34,8 +35,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
34
35
|
// Distance filtering
|
|
35
36
|
@Volatile private var maxDistance: Double? = null
|
|
36
37
|
@Volatile private var exitDistance: Double? = null
|
|
37
|
-
private val
|
|
38
|
+
private val monitoredRegionIds = java.util.concurrent.CopyOnWriteArraySet<String>()
|
|
38
39
|
private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
|
|
40
|
+
private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
|
39
41
|
|
|
40
42
|
// Hysteresis counters (synchronized on distanceLock)
|
|
41
43
|
private val distanceLock = Any()
|
|
@@ -89,10 +91,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
89
91
|
beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
|
|
90
92
|
BeaconParsers.ensureRegistered(manager)
|
|
91
93
|
try { manager.setEnableScheduledScanJobs(false) } catch (e: IllegalStateException) { Log.w(TAG, "setEnableScheduledScanJobs failed", e) }
|
|
92
|
-
manager.setBackgroundBetweenScanPeriod(
|
|
93
|
-
manager.setBackgroundScanPeriod(
|
|
94
|
-
manager.setForegroundScanPeriod(
|
|
95
|
-
manager.setForegroundBetweenScanPeriod(
|
|
94
|
+
manager.setBackgroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
|
|
95
|
+
manager.setBackgroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
|
|
96
|
+
manager.setForegroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
|
|
97
|
+
manager.setForegroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -156,6 +158,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
156
158
|
try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
157
159
|
}
|
|
158
160
|
distanceLogRegions.clear()
|
|
161
|
+
monitoredRegionIds.clear()
|
|
162
|
+
enteredRegions.clear()
|
|
163
|
+
lastSeenAtMs.clear()
|
|
164
|
+
timeoutHandler.removeCallbacksAndMessages(null)
|
|
165
|
+
timeoutRunnables.clear()
|
|
166
|
+
synchronized(distanceLock) {
|
|
167
|
+
enterCounters.clear()
|
|
168
|
+
exitCounters.clear()
|
|
169
|
+
missCounters.clear()
|
|
170
|
+
}
|
|
159
171
|
monitoredRegions.forEach {
|
|
160
172
|
try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
161
173
|
}
|
|
@@ -171,6 +183,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
171
183
|
Identifier.fromInt(b.getInt("minor"))
|
|
172
184
|
)
|
|
173
185
|
monitoredRegions.add(region)
|
|
186
|
+
monitoredRegionIds.add(region.uniqueId)
|
|
174
187
|
try {
|
|
175
188
|
beaconManager.startMonitoringBeaconsInRegion(region)
|
|
176
189
|
} catch (e: RemoteException) {
|
|
@@ -200,6 +213,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
200
213
|
null
|
|
201
214
|
)
|
|
202
215
|
monitoredRegions.add(region)
|
|
216
|
+
monitoredRegionIds.add(region.uniqueId)
|
|
203
217
|
try {
|
|
204
218
|
beaconManager.startMonitoringBeaconsInRegion(region)
|
|
205
219
|
} catch (ex: RemoteException) {
|
|
@@ -224,51 +238,42 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
224
238
|
|
|
225
239
|
// Distance logging only — emits distance broadcasts. Enter/exit logic lives in rangeNotifier.
|
|
226
240
|
private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
|
|
241
|
+
if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
|
|
227
242
|
val closest = beacons.filter { it.distance >= 0 }.minByOrNull { it.distance }
|
|
228
243
|
if (closest != null) {
|
|
244
|
+
lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
|
|
229
245
|
sendBeaconBroadcast(region, "distance", closest.distance)
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
private val monitorNotifier = object : MonitorNotifier {
|
|
234
250
|
override fun didEnterRegion(region: Region) {
|
|
235
|
-
|
|
236
|
-
if (maxDist != null) {
|
|
237
|
-
// Mark region for distance confirmation — ranging is already active via distance logging
|
|
238
|
-
rangingRegions.add(region)
|
|
239
|
-
} else {
|
|
240
|
-
enteredRegions.add(region.uniqueId)
|
|
241
|
-
sendBeaconBroadcast(region, "enter", -1.0)
|
|
242
|
-
showEnterExitNotification(region, "enter")
|
|
243
|
-
scheduleTimeoutIfConfigured(region)
|
|
244
|
-
}
|
|
251
|
+
// Enter is synthesized from ranging so distance and enter/exit stay in sync.
|
|
245
252
|
}
|
|
246
253
|
|
|
247
254
|
override fun didExitRegion(region: Region) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// Distance ranging normally handles exit. But if the beacon was
|
|
252
|
-
// in "entered" state when OS fires didExitRegion, we must emit
|
|
253
|
-
// the exit event — ranging will no longer receive readings.
|
|
254
|
-
val wasEntered = enteredRegions.remove(region.uniqueId)
|
|
255
|
-
synchronized(distanceLock) {
|
|
256
|
-
enterCounters.remove(region.uniqueId)
|
|
257
|
-
exitCounters.remove(region.uniqueId)
|
|
258
|
-
missCounters.remove(region.uniqueId)
|
|
259
|
-
}
|
|
260
|
-
if (wasEntered) {
|
|
261
|
-
cancelTimeout(region.uniqueId)
|
|
262
|
-
sendBeaconBroadcast(region, "exit", -1.0)
|
|
263
|
-
showEnterExitNotification(region, "exit")
|
|
264
|
-
}
|
|
255
|
+
if (!monitoredRegionIds.contains(region.uniqueId)) return
|
|
256
|
+
if (wasSeenRecently(region.uniqueId)) {
|
|
257
|
+
Log.d(TAG, "Ignoring stale didExitRegion for ${region.uniqueId}; beacon was seen by ranging recently")
|
|
265
258
|
return
|
|
266
259
|
}
|
|
267
260
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
261
|
+
lastSeenAtMs.remove(region.uniqueId)
|
|
262
|
+
|
|
263
|
+
// Ranging-based hysteresis handles exit in the normal case. If the OS
|
|
264
|
+
// fires didExitRegion after ranging has already stopped, emit exit as a
|
|
265
|
+
// safety net only if the region was previously in the entered state.
|
|
266
|
+
val wasEntered = enteredRegions.remove(region.uniqueId)
|
|
267
|
+
synchronized(distanceLock) {
|
|
268
|
+
enterCounters.remove(region.uniqueId)
|
|
269
|
+
exitCounters.remove(region.uniqueId)
|
|
270
|
+
missCounters.remove(region.uniqueId)
|
|
271
|
+
}
|
|
272
|
+
if (wasEntered) {
|
|
273
|
+
cancelTimeout(region.uniqueId)
|
|
274
|
+
sendBeaconBroadcast(region, "exit", -1.0)
|
|
275
|
+
showEnterExitNotification(region, "exit")
|
|
276
|
+
}
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
override fun didDetermineStateForRegion(state: Int, region: Region) {
|
|
@@ -277,11 +282,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
// Single source of truth for distance-based enter/exit with hysteresis.
|
|
280
|
-
// Processes regions
|
|
281
|
-
//
|
|
285
|
+
// Processes only actual monitoring regions and handles exit via miss counting
|
|
286
|
+
// when beacons disappear.
|
|
282
287
|
private val rangeNotifier = RangeNotifier { beacons, region ->
|
|
283
|
-
val maxDist = maxDistance
|
|
284
|
-
if (!
|
|
288
|
+
val maxDist = maxDistance
|
|
289
|
+
if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
|
|
285
290
|
|
|
286
291
|
val beacon = beacons
|
|
287
292
|
.filter { it.distance >= 0 }
|
|
@@ -290,13 +295,13 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
290
295
|
synchronized(distanceLock) {
|
|
291
296
|
if (beacon != null) {
|
|
292
297
|
// Got a valid reading — reset miss counter
|
|
298
|
+
lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
|
|
293
299
|
missCounters[region.uniqueId] = 0
|
|
294
300
|
|
|
295
301
|
val action = evaluateDistanceHysteresis(region.uniqueId, beacon.distance, maxDist)
|
|
296
302
|
when (action) {
|
|
297
303
|
HysteresisAction.ENTER -> {
|
|
298
304
|
enteredRegions.add(region.uniqueId)
|
|
299
|
-
rangingRegions.remove(region)
|
|
300
305
|
sendBeaconBroadcast(region, "enter", beacon.distance)
|
|
301
306
|
showEnterExitNotification(region, "enter")
|
|
302
307
|
scheduleTimeoutIfConfigured(region)
|
|
@@ -304,7 +309,6 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
304
309
|
HysteresisAction.EXIT -> {
|
|
305
310
|
cancelTimeout(region.uniqueId)
|
|
306
311
|
enteredRegions.remove(region.uniqueId)
|
|
307
|
-
rangingRegions.add(region)
|
|
308
312
|
sendBeaconBroadcast(region, "exit", beacon.distance)
|
|
309
313
|
showEnterExitNotification(region, "exit")
|
|
310
314
|
}
|
|
@@ -349,8 +353,23 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
349
353
|
private fun evaluateDistanceHysteresis(
|
|
350
354
|
regionId: String,
|
|
351
355
|
distance: Double,
|
|
352
|
-
maxDist: Double
|
|
356
|
+
maxDist: Double?
|
|
353
357
|
): HysteresisAction {
|
|
358
|
+
if (maxDist == null) {
|
|
359
|
+
exitCounters[regionId] = 0
|
|
360
|
+
if (enteredRegions.contains(regionId)) {
|
|
361
|
+
enterCounters[regionId] = 0
|
|
362
|
+
return HysteresisAction.NONE
|
|
363
|
+
}
|
|
364
|
+
val count = (enterCounters[regionId] ?: 0) + 1
|
|
365
|
+
enterCounters[regionId] = count
|
|
366
|
+
if (count >= HYSTERESIS_COUNT) {
|
|
367
|
+
enterCounters[regionId] = 0
|
|
368
|
+
return HysteresisAction.ENTER
|
|
369
|
+
}
|
|
370
|
+
return HysteresisAction.NONE
|
|
371
|
+
}
|
|
372
|
+
|
|
354
373
|
val exitDist = effectiveExitDistance(maxDist)
|
|
355
374
|
if (distance <= maxDist) {
|
|
356
375
|
// Inside enter threshold
|
|
@@ -378,6 +397,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
378
397
|
return HysteresisAction.NONE
|
|
379
398
|
}
|
|
380
399
|
|
|
400
|
+
private fun wasSeenRecently(regionId: String): Boolean {
|
|
401
|
+
val lastSeen = lastSeenAtMs[regionId] ?: return false
|
|
402
|
+
return SystemClock.elapsedRealtime() - lastSeen <= RECENT_RANGING_SIGHTING_GRACE_MS
|
|
403
|
+
}
|
|
404
|
+
|
|
381
405
|
// MARK: - Timeout timer helpers
|
|
382
406
|
|
|
383
407
|
private fun scheduleTimeoutIfConfigured(region: Region) {
|
|
@@ -578,14 +602,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
|
|
|
578
602
|
timeoutHandler.removeCallbacksAndMessages(null)
|
|
579
603
|
timeoutRunnables.clear()
|
|
580
604
|
beaconTimeouts.clear()
|
|
605
|
+
lastSeenAtMs.clear()
|
|
606
|
+
monitoredRegionIds.clear()
|
|
581
607
|
releaseEventLogger()
|
|
582
608
|
beaconManager.removeMonitorNotifier(monitorNotifier)
|
|
583
609
|
beaconManager.removeRangeNotifier(rangeNotifier)
|
|
584
610
|
beaconManager.removeRangeNotifier(distanceLoggingRangeNotifier)
|
|
585
|
-
rangingRegions.forEach {
|
|
586
|
-
try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
587
|
-
}
|
|
588
|
-
rangingRegions.clear()
|
|
589
611
|
distanceLogRegions.forEach {
|
|
590
612
|
try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
|
|
591
613
|
}
|
|
@@ -294,6 +294,22 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
|
+
if (maxDistance != null && (!maxDistance.isFinite() || maxDistance <= 0.0)) {
|
|
298
|
+
promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0", null)
|
|
299
|
+
return@AsyncFunction
|
|
300
|
+
}
|
|
301
|
+
if (exitDistance != null && (!exitDistance.isFinite() || exitDistance <= 0.0)) {
|
|
302
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0", null)
|
|
303
|
+
return@AsyncFunction
|
|
304
|
+
}
|
|
305
|
+
if (exitDistance != null && maxDistance == null) {
|
|
306
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set", null)
|
|
307
|
+
return@AsyncFunction
|
|
308
|
+
}
|
|
309
|
+
if (maxDistance != null && exitDistance != null && exitDistance < maxDistance) {
|
|
310
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance", null)
|
|
311
|
+
return@AsyncFunction
|
|
312
|
+
}
|
|
297
313
|
ctx.getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
|
|
298
314
|
.edit().apply {
|
|
299
315
|
if (maxDistance != null) putString("max_distance", maxDistance.toString())
|
|
@@ -313,6 +313,22 @@ public class ExpoBeaconModule: Module {
|
|
|
313
313
|
self.defaults.set(json, forKey: NOTIFICATION_CONFIG_KEY)
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
|
+
if let dist = maxDistance, (!dist.isFinite || dist <= 0) {
|
|
317
|
+
promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0")
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if let exitDist = exitDistance, (!exitDist.isFinite || exitDist <= 0) {
|
|
321
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0")
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if exitDistance != nil && maxDistance == nil {
|
|
325
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if let dist = maxDistance, let exitDist = exitDistance, exitDist < dist {
|
|
329
|
+
promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance")
|
|
330
|
+
return
|
|
331
|
+
}
|
|
316
332
|
if let dist = maxDistance {
|
|
317
333
|
self.defaults.set(dist, forKey: MAX_DISTANCE_KEY)
|
|
318
334
|
} else {
|
|
@@ -744,40 +760,45 @@ public class ExpoBeaconModule: Module {
|
|
|
744
760
|
// reliably regardless of advertisement rate.
|
|
745
761
|
let maxDist = self.defaults.object(forKey: MAX_DISTANCE_KEY) as? Double
|
|
746
762
|
let exitDist = self.defaults.object(forKey: EXIT_DISTANCE_KEY) as? Double
|
|
747
|
-
let
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
"
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
"
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
763
|
+
let hasValidDistance = distance.isFinite && distance >= 0
|
|
764
|
+
if hasValidDistance || maxDist == nil {
|
|
765
|
+
let action = evaluateDistanceHysteresis(
|
|
766
|
+
identifier: identifier,
|
|
767
|
+
distance: distance,
|
|
768
|
+
maxDistance: maxDist,
|
|
769
|
+
exitDistance: exitDist,
|
|
770
|
+
entered: &eddystoneEnteredRegions,
|
|
771
|
+
enterCtrs: &eddystoneEnterCounters,
|
|
772
|
+
exitCtrs: &eddystoneExitCounters
|
|
773
|
+
)
|
|
774
|
+
switch action {
|
|
775
|
+
case .enter:
|
|
776
|
+
sendLoggedEvent("onEddystoneEnter", [
|
|
777
|
+
"identifier": identifier,
|
|
778
|
+
"namespace": ns,
|
|
779
|
+
"instance": inst,
|
|
780
|
+
"event": "enter",
|
|
781
|
+
"distance": distance
|
|
782
|
+
])
|
|
783
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
784
|
+
scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
|
|
785
|
+
case .exit:
|
|
786
|
+
cancelEddystoneTimeout(identifier: identifier)
|
|
787
|
+
sendLoggedEvent("onEddystoneExit", [
|
|
788
|
+
"identifier": identifier,
|
|
789
|
+
"namespace": ns,
|
|
790
|
+
"instance": inst,
|
|
791
|
+
"event": "exit",
|
|
792
|
+
"distance": distance
|
|
793
|
+
])
|
|
794
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
795
|
+
case .none:
|
|
796
|
+
break
|
|
797
|
+
}
|
|
779
798
|
}
|
|
780
799
|
|
|
800
|
+
guard hasValidDistance else { break }
|
|
801
|
+
|
|
781
802
|
// Throttle distance events — enter/exit above is evaluated on every
|
|
782
803
|
// callback, but distance events are rate-limited to avoid flooding JS.
|
|
783
804
|
let now = Date()
|