expo-beacon 0.6.11 → 0.6.13

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.
@@ -288,9 +288,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
288
288
  missCounters.remove(region.uniqueId)
289
289
  }
290
290
  if (wasEntered) {
291
- cancelTimeout(region.uniqueId)
292
291
  sendBeaconBroadcast(region, "exit", -1.0)
293
292
  showEnterExitNotification(region, "exit")
293
+ // OS-level exit safety net — start the timeout clock.
294
+ scheduleTimeoutIfConfigured(region)
294
295
  }
295
296
  }
296
297
 
@@ -329,13 +330,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
329
330
  enteredRegions.add(region.uniqueId)
330
331
  sendBeaconBroadcast(region, "enter", beacon.distance, beacon.rssi)
331
332
  showEnterExitNotification(region, "enter")
332
- scheduleTimeoutIfConfigured(region)
333
+ // Beacon returned — cancel any running timeout timer.
334
+ cancelTimeout(region.uniqueId)
333
335
  }
334
336
  HysteresisAction.EXIT -> {
335
- cancelTimeout(region.uniqueId)
336
337
  enteredRegions.remove(region.uniqueId)
337
338
  sendBeaconBroadcast(region, "exit", beacon.distance, beacon.rssi)
338
339
  showEnterExitNotification(region, "exit")
340
+ // Beacon left — start the timeout clock.
341
+ scheduleTimeoutIfConfigured(region)
339
342
  }
340
343
  HysteresisAction.NONE -> {}
341
344
  }
@@ -348,18 +351,14 @@ class BeaconForegroundService : Service(), BeaconConsumer {
348
351
  missCounters[region.uniqueId] = count
349
352
 
350
353
  if (enteredRegions.contains(region.uniqueId) && count >= EXIT_MISS_THRESHOLD) {
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.
357
354
  enteredRegions.remove(region.uniqueId)
358
355
  missCounters[region.uniqueId] = 0
359
356
  enterCounters[region.uniqueId] = 0
360
357
  exitCounters[region.uniqueId] = 0
361
358
  sendBeaconBroadcast(region, "exit", -1.0)
362
359
  showEnterExitNotification(region, "exit")
360
+ // Beacon disappeared — start the timeout clock.
361
+ scheduleTimeoutIfConfigured(region)
363
362
  }
364
363
  }
365
364
  }
@@ -459,14 +458,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
459
458
 
460
459
  private fun scheduleTimeoutIfConfigured(region: Region) {
461
460
  val seconds = beaconTimeouts[region.uniqueId] ?: return
462
- // Cancel any existing timer (shouldn't happen, but be safe)
461
+ // Cancel any existing timer so each exit resets the clock.
463
462
  cancelTimeout(region.uniqueId)
464
463
  val runnable = Runnable {
465
464
  timeoutRunnables.remove(region.uniqueId)
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
465
  sendBeaconBroadcast(region, "timeout", -1.0)
471
466
  }
472
467
  timeoutRunnables[region.uniqueId] = runnable
@@ -848,9 +848,10 @@ public class ExpoBeaconModule: Module {
848
848
  "rssi": beaconRssi
849
849
  ])
850
850
  postBeaconNotification(identifier: identifier, eventType: "enter")
851
- scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
852
- case .exit:
851
+ // Beacon returned cancel any running timeout timer.
853
852
  cancelEddystoneTimeout(identifier: identifier)
853
+ case .exit:
854
+ smoothedDistances.removeValue(forKey: identifier)
854
855
  sendLoggedEvent("onEddystoneExit", [
855
856
  "identifier": identifier,
856
857
  "namespace": ns,
@@ -860,6 +861,8 @@ public class ExpoBeaconModule: Module {
860
861
  "rssi": beaconRssi
861
862
  ])
862
863
  postBeaconNotification(identifier: identifier, eventType: "exit")
864
+ // Beacon left — start the timeout clock.
865
+ scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
863
866
  case .none:
864
867
  break
865
868
  }
@@ -1078,7 +1081,7 @@ public class ExpoBeaconModule: Module {
1078
1081
  eddystoneEnterCounters[identifier] = 0
1079
1082
  eddystoneExitCounters[identifier] = 0
1080
1083
  eddystoneLatestSeen.removeValue(forKey: identifier)
1081
- // Do NOT cancel the timeout here — same reason as iBeacon miss-based exit.
1084
+ smoothedDistances.removeValue(forKey: identifier)
1082
1085
 
1083
1086
  let ns = paired["namespace"] as? String ?? ""
1084
1087
  let inst = paired["instance"] as? String ?? ""
@@ -1091,16 +1094,17 @@ public class ExpoBeaconModule: Module {
1091
1094
  ]
1092
1095
  sendLoggedEvent("onEddystoneExit", params)
1093
1096
  postBeaconNotification(identifier: identifier, eventType: "exit")
1097
+ // Beacon disappeared — start the timeout clock.
1098
+ scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
1094
1099
  }
1095
1100
  }
1096
1101
  }
1097
1102
 
1098
1103
  // MARK: - Timeout timer helpers
1099
1104
 
1100
- private func scheduleBeaconTimeout(identifier: String, beacon: CLBeacon) {
1101
- // Cancel any existing timer for this identifier (shouldn't happen, but be safe)
1102
- beaconTimeoutTimers[identifier]?.cancel()
1103
- beaconTimeoutTimers.removeValue(forKey: identifier)
1105
+ private func scheduleBeaconTimeout(identifier: String, beacon: CLBeacon? = nil, region: CLBeaconRegion? = nil) {
1106
+ // Cancel any existing timer so each exit resets the clock.
1107
+ cancelBeaconTimeout(identifier: identifier)
1104
1108
 
1105
1109
  let paired = loadPairedBeaconsRaw().first { ($0["identifier"] as? String) == identifier }
1106
1110
  guard let seconds = paired?["timeoutSeconds"] as? Int, seconds > 0 else { return }
@@ -1108,11 +1112,7 @@ public class ExpoBeaconModule: Module {
1108
1112
  let work = DispatchWorkItem { [weak self] in
1109
1113
  guard let self = self else { return }
1110
1114
  self.beaconTimeoutTimers.removeValue(forKey: identifier)
1111
- // Fire unconditionally. A miss-based exit may have cleared enteredRegions before
1112
- // the timer elapsed (ranging gaps can cause false exits), but the beacon may still
1113
- // be physically present. Distance-based exits call cancelBeaconTimeout() so this
1114
- // work item is cancelled before it runs on genuine out-of-range departures.
1115
- self.sendLoggedEvent("onBeaconTimeout", self.makeBeaconEventParams(identifier: identifier, beacon: beacon))
1115
+ self.sendLoggedEvent("onBeaconTimeout", self.makeBeaconEventParams(identifier: identifier, beacon: beacon, region: region))
1116
1116
  }
1117
1117
  beaconTimeoutTimers[identifier] = work
1118
1118
  DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(seconds), execute: work)
@@ -1123,8 +1123,8 @@ public class ExpoBeaconModule: Module {
1123
1123
  }
1124
1124
 
1125
1125
  private func scheduleEddystoneTimeout(identifier: String, namespace: String, instance: String) {
1126
- eddystoneTimeoutTimers[identifier]?.cancel()
1127
- eddystoneTimeoutTimers.removeValue(forKey: identifier)
1126
+ // Cancel any existing timer so each exit resets the clock.
1127
+ cancelEddystoneTimeout(identifier: identifier)
1128
1128
 
1129
1129
  let paired = loadPairedEddystonesRaw().first { ($0["identifier"] as? String) == identifier }
1130
1130
  guard let seconds = paired?["timeoutSeconds"] as? Int, seconds > 0 else { return }
@@ -1132,7 +1132,6 @@ public class ExpoBeaconModule: Module {
1132
1132
  let work = DispatchWorkItem { [weak self] in
1133
1133
  guard let self = self else { return }
1134
1134
  self.eddystoneTimeoutTimers.removeValue(forKey: identifier)
1135
- // Fire unconditionally — same reason as iBeacon timeout.
1136
1135
  self.sendLoggedEvent("onEddystoneTimeout", [
1137
1136
  "identifier": identifier,
1138
1137
  "namespace": namespace,
@@ -1197,16 +1196,21 @@ public class ExpoBeaconModule: Module {
1197
1196
  // MARK: - Distance smoothing + enter/exit hysteresis
1198
1197
 
1199
1198
  /// Apply exponential moving average (EMA) smoothing to a raw distance reading.
1200
- /// Returns nil if the reading is a jump outlier (raw differs from smoothed by > DISTANCE_JUMP_FACTOR).
1199
+ /// If the reading is a large jump (> DISTANCE_JUMP_FACTOR), resets the EMA to the new
1200
+ /// value instead of rejecting it — this ensures distance events keep flowing when the
1201
+ /// user moves away from a beacon, rather than freezing because the EMA is stuck at the
1202
+ /// old close-range value and every new far-range reading is rejected.
1201
1203
  private func smoothDistance(identifier: String, rawDistance: Double) -> Double? {
1202
1204
  guard let prev = smoothedDistances[identifier] else {
1203
1205
  smoothedDistances[identifier] = rawDistance
1204
1206
  return rawDistance
1205
1207
  }
1206
- // Jump guard: if the raw value is wildly different, treat as outlier
1208
+ // Jump guard: if the raw value is wildly different, reset EMA to the new reading
1209
+ // so the hysteresis pipeline keeps receiving data and can fire the exit event.
1207
1210
  let ratio = prev > 0.001 ? rawDistance / prev : rawDistance
1208
1211
  if ratio > Self.DISTANCE_JUMP_FACTOR || (ratio > 0 && ratio < 1.0 / Self.DISTANCE_JUMP_FACTOR) {
1209
- return nil
1212
+ smoothedDistances[identifier] = rawDistance
1213
+ return rawDistance
1210
1214
  }
1211
1215
  let smoothed = Self.DISTANCE_EMA_ALPHA * rawDistance + (1 - Self.DISTANCE_EMA_ALPHA) * prev
1212
1216
  smoothedDistances[identifier] = smoothed
@@ -1354,11 +1358,14 @@ public class ExpoBeaconModule: Module {
1354
1358
  case .enter:
1355
1359
  sendLoggedEvent("onBeaconEnter", makeBeaconEventParams(identifier: identifier, beacon: beacon, event: "enter"))
1356
1360
  postBeaconNotification(identifier: identifier, eventType: "enter")
1357
- scheduleBeaconTimeout(identifier: identifier, beacon: beacon)
1358
- case .exit:
1361
+ // Beacon returned — cancel any running timeout timer.
1359
1362
  cancelBeaconTimeout(identifier: identifier)
1363
+ case .exit:
1364
+ smoothedDistances.removeValue(forKey: identifier)
1360
1365
  sendLoggedEvent("onBeaconExit", makeBeaconEventParams(identifier: identifier, beacon: beacon, event: "exit"))
1361
1366
  postBeaconNotification(identifier: identifier, eventType: "exit")
1367
+ // Beacon left — start the timeout clock.
1368
+ scheduleBeaconTimeout(identifier: identifier, beacon: beacon)
1362
1369
  case .none:
1363
1370
  break
1364
1371
  }
@@ -1379,16 +1386,14 @@ public class ExpoBeaconModule: Module {
1379
1386
  missCounters[identifier] = 0
1380
1387
  enterCounters[identifier] = 0
1381
1388
  exitCounters[identifier] = 0
1382
- // Do NOT cancel the timeout here. A miss-based exit is triggered by
1383
- // ranging gaps (e.g. accuracy == -1), not a confirmed physical departure.
1384
- // Cancelling here prevents the timeout from firing when it is longer than
1385
- // the miss window. Distance-based exits call cancelBeaconTimeout() so the
1386
- // timer is still cancelled on genuine out-of-range events.
1389
+ smoothedDistances.removeValue(forKey: identifier)
1387
1390
 
1388
1391
  // Look up region info for the exit event payload
1389
1392
  let region = monitoredRegions.first { $0.identifier == identifier }
1390
1393
  sendLoggedEvent("onBeaconExit", makeBeaconEventParams(identifier: identifier, region: region, event: "exit"))
1391
1394
  postBeaconNotification(identifier: identifier, eventType: "exit")
1395
+ // Beacon disappeared — start the timeout clock.
1396
+ scheduleBeaconTimeout(identifier: identifier, region: region)
1392
1397
  }
1393
1398
  }
1394
1399
  return
@@ -1429,10 +1434,12 @@ public class ExpoBeaconModule: Module {
1429
1434
  enterCounters.removeValue(forKey: identifier)
1430
1435
  exitCounters.removeValue(forKey: identifier)
1431
1436
  missCounters.removeValue(forKey: identifier)
1437
+ smoothedDistances.removeValue(forKey: identifier)
1432
1438
  if wasEntered {
1433
- cancelBeaconTimeout(identifier: identifier)
1434
1439
  sendLoggedEvent("onBeaconExit", makeBeaconEventParams(identifier: identifier, region: beaconRegion, event: "exit"))
1435
1440
  postBeaconNotification(identifier: identifier, eventType: "exit")
1441
+ // OS-level exit safety net — start the timeout clock.
1442
+ scheduleBeaconTimeout(identifier: identifier, region: beaconRegion)
1436
1443
  }
1437
1444
  }
1438
1445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
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",