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.
@@ -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 rangingRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
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(5000L) // 5s between scans
93
- manager.setBackgroundScanPeriod(1100L) // 1.1s scan window
94
- manager.setForegroundScanPeriod(1000L) // 1s scan window for distance logging
95
- manager.setForegroundBetweenScanPeriod(0L) // no pause between scans
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
- val maxDist = maxDistance
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
- rangingRegions.remove(region)
249
-
250
- if (maxDistance != null) {
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
- cancelTimeout(region.uniqueId)
269
- enteredRegions.remove(region.uniqueId)
270
- sendBeaconBroadcast(region, "exit", -1.0)
271
- showEnterExitNotification(region, "exit")
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 added to rangingRegions by monitorNotifier.didEnterRegion,
281
- // and also handles exit via miss counting when beacons disappear.
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 ?: return@RangeNotifier
284
- if (!rangingRegions.contains(region) && !enteredRegions.contains(region.uniqueId)) return@RangeNotifier
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 action = evaluateDistanceHysteresis(
748
- identifier: identifier,
749
- distance: distance,
750
- maxDistance: maxDist,
751
- exitDistance: exitDist,
752
- entered: &eddystoneEnteredRegions,
753
- enterCtrs: &eddystoneEnterCounters,
754
- exitCtrs: &eddystoneExitCounters
755
- )
756
- switch action {
757
- case .enter:
758
- sendLoggedEvent("onEddystoneEnter", [
759
- "identifier": identifier,
760
- "namespace": ns,
761
- "instance": inst,
762
- "event": "enter",
763
- "distance": distance
764
- ])
765
- postBeaconNotification(identifier: identifier, eventType: "enter")
766
- scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
767
- case .exit:
768
- cancelEddystoneTimeout(identifier: identifier)
769
- sendLoggedEvent("onEddystoneExit", [
770
- "identifier": identifier,
771
- "namespace": ns,
772
- "instance": inst,
773
- "event": "exit",
774
- "distance": distance
775
- ])
776
- postBeaconNotification(identifier: identifier, eventType: "exit")
777
- case .none:
778
- break
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Expo module for scanning, pairing, and monitoring iBeacons on Android and iOS",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",