expo-beacon 0.2.0 → 0.3.1

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.
@@ -1,5 +1,6 @@
1
1
  import ExpoModulesCore
2
2
  import CoreLocation
3
+ import CoreBluetooth
3
4
  import UserNotifications
4
5
 
5
6
  private let PAIRED_BEACONS_KEY = "expo.beacon.paired"
@@ -7,21 +8,30 @@ private let IS_MONITORING_KEY = "expo.beacon.is_monitoring"
7
8
  private let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
8
9
  private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
9
10
 
10
- public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
11
+ /// Number of consecutive ranging misses before emitting a distance-based exit event.
12
+ private let EXIT_MISS_THRESHOLD = 3
13
+ /// Number of consecutive readings required to confirm a distance-based enter or exit transition.
14
+ private let HYSTERESIS_COUNT = 3
15
+
16
+ public class ExpoBeaconModule: Module {
17
+
18
+ private lazy var locationDelegate = LocationDelegate(module: self)
11
19
 
12
20
  private lazy var locationManager: CLLocationManager = {
13
21
  let manager = CLLocationManager()
14
- manager.delegate = self
15
- manager.allowsBackgroundLocationUpdates = true
22
+ manager.delegate = locationDelegate
23
+ let backgroundModes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] ?? []
24
+ if backgroundModes.contains("location") {
25
+ manager.allowsBackgroundLocationUpdates = true
26
+ }
16
27
  manager.pausesLocationUpdatesAutomatically = false
17
28
  return manager
18
29
  }()
19
30
 
20
31
  // One-shot scan state
21
- private var scanPromise: Promise?
32
+ fileprivate var scanPromise: Promise?
22
33
  private var scannedBeacons: [CLBeacon] = []
23
- private var scanConstraint: CLBeaconIdentityConstraint?
24
- private var scanRegion: CLBeaconRegion?
34
+ private var scanConstraints: [CLBeaconIdentityConstraint] = []
25
35
 
26
36
  // Monitored regions
27
37
  private var monitoredRegions: [CLBeaconRegion] = []
@@ -30,29 +40,60 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
30
40
  private var distanceRangingConstraints: [String: CLBeaconIdentityConstraint] = [:]
31
41
  // Identifiers currently in "entered" state (used for distance-driven enter/exit)
32
42
  private var enteredRegions: Set<String> = []
43
+ // Consecutive miss counter per identifier (for distance-based exit when beacon disappears)
44
+ private var missCounters: [String: Int] = [:]
45
+ // Hysteresis counters: consecutive readings inside/outside threshold per identifier
46
+ private var enterCounters: [String: Int] = [:]
47
+ private var exitCounters: [String: Int] = [:]
33
48
 
34
49
  // Continuous scan state
35
50
  private var continuousScanActive = false
36
51
  // Constraints started exclusively for continuous scan (not shared with distance ranging)
37
52
  private var continuousScanOnlyConstraints: [CLBeaconIdentityConstraint] = []
38
53
 
54
+ // Wildcard (CoreBluetooth) scan state
55
+ private lazy var bluetoothDelegate = BluetoothDelegate(module: self)
56
+ private var centralManager: CBCentralManager?
57
+ private var wildcardScannedBeacons: [[String: Any]] = []
58
+ private var wildcardScanTimer: DispatchWorkItem?
59
+
39
60
  // Permission callback
40
61
  private var permissionCompletion: ((Bool) -> Void)?
41
62
 
42
63
  public func definition() -> ModuleDefinition {
43
64
  Name("ExpoBeacon")
44
65
 
45
- Events("onBeaconEnter", "onBeaconExit", "onBeaconRanging", "onBeaconDistance", "onBeaconFound")
66
+ Events("onBeaconEnter", "onBeaconExit", "onBeaconDistance", "onBeaconFound")
46
67
 
47
68
  // MARK: - Scan
48
69
 
49
- AsyncFunction("scanForBeaconsAsync") { (scanDurationMs: Int, promise: Promise) in
70
+ AsyncFunction("scanForBeaconsAsync") { (uuids: [String], scanDurationMs: Int, promise: Promise) in
50
71
  guard self.scanPromise == nil else {
51
72
  promise.reject("SCAN_IN_PROGRESS", "A scan is already in progress")
52
73
  return
53
74
  }
75
+
54
76
  self.scanPromise = promise
77
+
78
+ // Wildcard scan (empty UUIDs) — use CoreBluetooth raw BLE scanning
79
+ if uuids.isEmpty {
80
+ self.startWildcardScan(durationMs: scanDurationMs)
81
+ return
82
+ }
83
+
84
+ // UUID-targeted scan — use CoreLocation ranging
85
+ var parsedUUIDs: [UUID] = []
86
+ for uuidStr in uuids {
87
+ guard let uuid = UUID(uuidString: uuidStr) else {
88
+ promise.reject("INVALID_UUID", "Invalid UUID: \(uuidStr)")
89
+ self.scanPromise = nil
90
+ return
91
+ }
92
+ parsedUUIDs.append(uuid)
93
+ }
94
+
55
95
  self.scannedBeacons = []
96
+ self.scanConstraints = []
56
97
 
57
98
  self.requestLocationPermission { granted in
58
99
  guard granted else {
@@ -61,12 +102,12 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
61
102
  return
62
103
  }
63
104
 
64
- // iOS ranging requires a specific UUID; use a placeholder for generic scan
65
- let placeholderUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
66
- let region = CLBeaconRegion(uuid: placeholderUUID, identifier: "scan_wildcard")
67
- self.scanRegion = region
68
- self.scanConstraint = CLBeaconIdentityConstraint(uuid: placeholderUUID)
69
- self.locationManager.startRangingBeacons(satisfying: self.scanConstraint!)
105
+ // Range for each requested UUID simultaneously
106
+ for uuid in parsedUUIDs {
107
+ let constraint = CLBeaconIdentityConstraint(uuid: uuid)
108
+ self.scanConstraints.append(constraint)
109
+ self.locationManager.startRangingBeacons(satisfying: constraint)
110
+ }
70
111
 
71
112
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(scanDurationMs)) {
72
113
  self.stopScanAndResolve()
@@ -76,7 +117,17 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
76
117
 
77
118
  // MARK: - Pair
78
119
 
79
- Function("pairBeacon") { (identifier: String, uuid: String, major: Int, minor: Int) in
120
+ Function("pairBeacon") { (identifier: String, uuid: String, major: Int, minor: Int) -> Void in
121
+ guard UUID(uuidString: uuid) != nil else {
122
+ throw Exception(name: "INVALID_UUID", description: "Invalid UUID format: \(uuid)")
123
+ }
124
+ guard (0...65535).contains(major) else {
125
+ throw Exception(name: "INVALID_MAJOR", description: "Major must be 0–65535, got \(major)")
126
+ }
127
+ guard (0...65535).contains(minor) else {
128
+ throw Exception(name: "INVALID_MINOR", description: "Minor must be 0–65535, got \(minor)")
129
+ }
130
+
80
131
  var beacons = self.loadPairedBeaconsRaw()
81
132
  beacons.removeAll { ($0["identifier"] as? String) == identifier }
82
133
  beacons.append([
@@ -109,11 +160,11 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
109
160
 
110
161
  // MARK: - Monitoring
111
162
 
112
- AsyncFunction("startMonitoring") { (options: Any?, promise: Promise) in
163
+ AsyncFunction("startMonitoring") { (options: Either<Double, [String: Any]>?, promise: Promise) in
113
164
  var maxDistance: Double? = nil
114
- if let dist = options as? Double {
165
+ if let dist: Double = options?.get() {
115
166
  maxDistance = dist
116
- } else if let map = options as? [String: Any] {
167
+ } else if let map: [String: Any] = options?.get() {
117
168
  maxDistance = map["maxDistance"] as? Double
118
169
  if let notifications = map["notifications"] as? [String: Any],
119
170
  let data = try? JSONSerialization.data(withJSONObject: notifications),
@@ -127,7 +178,7 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
127
178
  UserDefaults.standard.removeObject(forKey: MAX_DISTANCE_KEY)
128
179
  }
129
180
  UserDefaults.standard.set(true, forKey: IS_MONITORING_KEY)
130
- self.requestLocationPermission { granted in
181
+ self.requestLocationPermission(requireAlways: true) { granted in
131
182
  guard granted else {
132
183
  promise.reject("PERMISSION_DENIED", "Always location permission required for background monitoring")
133
184
  return
@@ -170,17 +221,41 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
170
221
 
171
222
  // MARK: - Private Helpers
172
223
 
173
- private func requestLocationPermission(completion: @escaping (Bool) -> Void) {
224
+ private func requestLocationPermission(requireAlways: Bool = false, completion: @escaping (Bool) -> Void) {
174
225
  let status = locationManager.authorizationStatus
175
226
  switch status {
176
227
  case .authorizedAlways:
177
228
  completion(true)
229
+ case .authorizedWhenInUse:
230
+ if requireAlways {
231
+ // Already have whenInUse — request upgrade to always
232
+ self.permissionCompletion = { granted in
233
+ // After the upgrade prompt, only .authorizedAlways counts
234
+ let nowStatus = self.locationManager.authorizationStatus
235
+ completion(nowStatus == .authorizedAlways)
236
+ }
237
+ locationManager.requestAlwaysAuthorization()
238
+ } else {
239
+ completion(true)
240
+ }
178
241
  case .notDetermined:
179
- self.permissionCompletion = completion
180
- locationManager.requestAlwaysAuthorization()
242
+ // Two-step flow: first request whenInUse, then upgrade to always
243
+ self.permissionCompletion = { _ in
244
+ let nowStatus = self.locationManager.authorizationStatus
245
+ if requireAlways && nowStatus == .authorizedWhenInUse {
246
+ // Got provisional whenInUse — request upgrade to always
247
+ self.permissionCompletion = { _ in
248
+ let finalStatus = self.locationManager.authorizationStatus
249
+ completion(finalStatus == .authorizedAlways)
250
+ }
251
+ self.locationManager.requestAlwaysAuthorization()
252
+ } else {
253
+ completion(nowStatus == .authorizedAlways || nowStatus == .authorizedWhenInUse)
254
+ }
255
+ }
256
+ locationManager.requestWhenInUseAuthorization()
181
257
  default:
182
- // WhenInUse allows foreground ranging but not background region monitoring
183
- completion(status == .authorizedWhenInUse)
258
+ completion(false)
184
259
  }
185
260
  }
186
261
 
@@ -236,6 +311,9 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
236
311
  }
237
312
  distanceRangingConstraints.removeAll()
238
313
  enteredRegions.removeAll()
314
+ missCounters.removeAll()
315
+ enterCounters.removeAll()
316
+ exitCounters.removeAll()
239
317
  }
240
318
 
241
319
  // Start ranging for paired beacons not already covered by distance ranging
@@ -264,11 +342,10 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
264
342
  }
265
343
 
266
344
  private func stopScanAndResolve() {
267
- if let constraint = scanConstraint {
345
+ for constraint in scanConstraints {
268
346
  locationManager.stopRangingBeacons(satisfying: constraint)
269
- scanConstraint = nil
270
- scanRegion = nil
271
347
  }
348
+ scanConstraints.removeAll()
272
349
 
273
350
  var seen = Set<String>()
274
351
  let results: [[String: Any]] = scannedBeacons.compactMap { beacon in
@@ -290,6 +367,96 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
290
367
  scannedBeacons = []
291
368
  }
292
369
 
370
+ // MARK: - Wildcard (CoreBluetooth) scan
371
+
372
+ private func startWildcardScan(durationMs: Int) {
373
+ wildcardScannedBeacons = []
374
+
375
+ if centralManager == nil {
376
+ // Creating CBCentralManager triggers a Bluetooth power-on check.
377
+ // Once .poweredOn, the delegate calls beginWildcardScanning().
378
+ centralManager = CBCentralManager(delegate: bluetoothDelegate, queue: .main)
379
+ } else if centralManager?.state == .poweredOn {
380
+ beginWildcardScanning()
381
+ }
382
+ // If state is not yet .poweredOn the delegate will call beginWildcardScanning()
383
+ // when centralManagerDidUpdateState fires.
384
+
385
+ let timer = DispatchWorkItem { [weak self] in
386
+ self?.stopWildcardScanAndResolve()
387
+ }
388
+ wildcardScanTimer = timer
389
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(durationMs), execute: timer)
390
+ }
391
+
392
+ fileprivate func beginWildcardScanning() {
393
+ guard scanPromise != nil, wildcardScanTimer != nil else { return }
394
+ centralManager?.scanForPeripherals(
395
+ withServices: nil,
396
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
397
+ )
398
+ }
399
+
400
+ fileprivate func handleWildcardDiscovery(advertisementData: [String: Any], rssi: NSNumber) {
401
+ guard let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
402
+ mfgData.count >= 25 else { return }
403
+
404
+ // iBeacon format: Apple company ID (0x4C 0x00) + type 0x02 + length 0x15
405
+ guard mfgData[0] == 0x4C, mfgData[1] == 0x00,
406
+ mfgData[2] == 0x02, mfgData[3] == 0x15 else { return }
407
+
408
+ // Parse UUID (bytes 4–19)
409
+ let uuidBytes = [UInt8](mfgData[4..<20])
410
+ let uuid = NSUUID(uuidBytes: uuidBytes) as UUID
411
+
412
+ // Parse major (bytes 20–21, big-endian) and minor (bytes 22–23, big-endian)
413
+ let major = Int(UInt16(mfgData[20]) << 8 | UInt16(mfgData[21]))
414
+ let minor = Int(UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]))
415
+
416
+ // TX power (byte 24, signed)
417
+ let txPower = Int(Int8(bitPattern: mfgData[24]))
418
+
419
+ let rssiValue = rssi.intValue
420
+ let distance = Self.calculateDistance(rssi: rssiValue, txPower: txPower)
421
+
422
+ let result: [String: Any] = [
423
+ "uuid": uuid.uuidString.uppercased(),
424
+ "major": major,
425
+ "minor": minor,
426
+ "rssi": rssiValue,
427
+ "distance": distance,
428
+ "txPower": txPower
429
+ ]
430
+ wildcardScannedBeacons.append(result)
431
+ }
432
+
433
+ private func stopWildcardScanAndResolve() {
434
+ wildcardScanTimer?.cancel()
435
+ wildcardScanTimer = nil
436
+ centralManager?.stopScan()
437
+
438
+ // Deduplicate by uuid:major:minor, keeping the last (freshest) reading
439
+ var seen = Set<String>()
440
+ var deduped: [[String: Any]] = []
441
+ for beacon in wildcardScannedBeacons.reversed() {
442
+ let key = "\(beacon["uuid"] ?? ""):\(beacon["major"] ?? ""):\(beacon["minor"] ?? "")"
443
+ guard !seen.contains(key) else { continue }
444
+ seen.insert(key)
445
+ deduped.append(beacon)
446
+ }
447
+
448
+ scanPromise?.resolve(deduped)
449
+ scanPromise = nil
450
+ wildcardScannedBeacons = []
451
+ }
452
+
453
+ /// Log-distance path loss model: distance = 10 ^ ((txPower - rssi) / (10 * n)), n = 2.0
454
+ private static func calculateDistance(rssi: Int, txPower: Int) -> Double {
455
+ guard rssi != 0 else { return -1 }
456
+ let ratio = Double(txPower - rssi) / 20.0
457
+ return pow(10.0, ratio)
458
+ }
459
+
293
460
  private func loadPairedBeaconsRaw() -> [[String: Any]] {
294
461
  return UserDefaults.standard.array(forKey: PAIRED_BEACONS_KEY) as? [[String: Any]] ?? []
295
462
  }
@@ -342,79 +509,119 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
342
509
  return a.uuid == b.uuid && a.major == b.major && a.minor == b.minor
343
510
  }
344
511
 
345
- // MARK: - CLLocationManagerDelegate
512
+ // MARK: - CLLocationManagerDelegate handlers (called by LocationDelegate)
346
513
 
347
- public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
514
+ fileprivate func handleDidChangeAuthorization(_ status: CLAuthorizationStatus) {
348
515
  let granted = (status == .authorizedAlways || status == .authorizedWhenInUse)
349
516
  permissionCompletion?(granted)
350
517
  permissionCompletion = nil
351
518
  }
352
519
 
353
- public func locationManager(_ manager: CLLocationManager, didRangeBeacons beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) {
520
+ fileprivate func handleDidRange(_ beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) {
354
521
  // 1. One-shot scan mode
355
- if let sc = scanConstraint, constraintMatches(sc, constraint) {
522
+ if scanConstraints.contains(where: { constraintMatches($0, constraint) }) {
356
523
  scannedBeacons.append(contentsOf: beacons)
357
524
  return
358
525
  }
359
526
 
360
527
  // 2. Distance-ranging for monitored beacons
361
528
  if let (identifier, _) = distanceRangingConstraints.first(where: { constraintMatches($0.value, constraint) }) {
362
- guard let beacon = beacons.first(where: { $0.accuracy >= 0 }) else {
363
- return
364
- }
529
+ let validBeacon = beacons.first(where: { $0.accuracy >= 0 })
365
530
 
366
- // Emit distance event every ranging cycle (~1 s)
367
- let distParams: [String: Any] = [
368
- "identifier": identifier,
369
- "uuid": beacon.uuid.uuidString.uppercased(),
370
- "major": beacon.major.intValue,
371
- "minor": beacon.minor.intValue,
372
- "distance": beacon.accuracy
373
- ]
374
- sendEvent("onBeaconDistance", distParams)
375
- print("[ExpoBeacon] DIST: \(identifier) → \(String(format: "%.2f", beacon.accuracy))m")
531
+ if let beacon = validBeacon {
532
+ // Got a valid reading — reset miss counter
533
+ missCounters[identifier] = 0
376
534
 
377
- // Distance-driven enter/exit synthesis
378
- if let maxDist = UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) as? Double {
379
- if !enteredRegions.contains(identifier) && beacon.accuracy <= maxDist {
380
- enteredRegions.insert(identifier)
381
- let params: [String: Any] = [
382
- "identifier": identifier,
535
+ // Emit distance event every ranging cycle (~1 s)
536
+ let distParams: [String: Any] = [
537
+ "identifier": identifier,
538
+ "uuid": beacon.uuid.uuidString.uppercased(),
539
+ "major": beacon.major.intValue,
540
+ "minor": beacon.minor.intValue,
541
+ "distance": beacon.accuracy
542
+ ]
543
+ sendEvent("onBeaconDistance", distParams)
544
+
545
+ // Distance-driven enter/exit synthesis with hysteresis
546
+ if let maxDist = UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) as? Double {
547
+ if beacon.accuracy <= maxDist {
548
+ // Reading is inside threshold
549
+ exitCounters[identifier] = 0
550
+ enterCounters[identifier] = (enterCounters[identifier] ?? 0) + 1
551
+
552
+ if !enteredRegions.contains(identifier) && (enterCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
553
+ enteredRegions.insert(identifier)
554
+ enterCounters[identifier] = 0
555
+ let params: [String: Any] = [
556
+ "identifier": identifier,
557
+ "uuid": beacon.uuid.uuidString.uppercased(),
558
+ "major": beacon.major.intValue,
559
+ "minor": beacon.minor.intValue,
560
+ "event": "enter",
561
+ "distance": beacon.accuracy
562
+ ]
563
+ sendEvent("onBeaconEnter", params)
564
+ postBeaconNotification(identifier: identifier, eventType: "enter")
565
+ }
566
+ } else {
567
+ // Reading is outside threshold
568
+ enterCounters[identifier] = 0
569
+ exitCounters[identifier] = (exitCounters[identifier] ?? 0) + 1
570
+
571
+ if enteredRegions.contains(identifier) && (exitCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
572
+ enteredRegions.remove(identifier)
573
+ exitCounters[identifier] = 0
574
+ let params: [String: Any] = [
575
+ "identifier": identifier,
576
+ "uuid": beacon.uuid.uuidString.uppercased(),
577
+ "major": beacon.major.intValue,
578
+ "minor": beacon.minor.intValue,
579
+ "event": "exit",
580
+ "distance": beacon.accuracy
581
+ ]
582
+ sendEvent("onBeaconExit", params)
583
+ postBeaconNotification(identifier: identifier, eventType: "exit")
584
+ }
585
+ }
586
+ }
587
+
588
+ // Also emit onBeaconFound if continuous scan is active for this beacon
589
+ if continuousScanActive {
590
+ let foundParams: [String: Any] = [
383
591
  "uuid": beacon.uuid.uuidString.uppercased(),
384
592
  "major": beacon.major.intValue,
385
593
  "minor": beacon.minor.intValue,
386
- "event": "enter",
387
- "distance": beacon.accuracy
594
+ "rssi": beacon.rssi,
595
+ "distance": beacon.accuracy,
596
+ "txPower": 0
388
597
  ]
389
- sendEvent("onBeaconEnter", params)
390
- postBeaconNotification(identifier: identifier, eventType: "enter")
391
- } else if enteredRegions.contains(identifier) && beacon.accuracy > maxDist {
598
+ sendEvent("onBeaconFound", foundParams)
599
+ }
600
+ } else {
601
+ // No valid beacon reading — beacon may have disappeared
602
+ let count = (missCounters[identifier] ?? 0) + 1
603
+ missCounters[identifier] = count
604
+
605
+ if enteredRegions.contains(identifier) && count >= EXIT_MISS_THRESHOLD {
392
606
  enteredRegions.remove(identifier)
607
+ missCounters[identifier] = 0
608
+ enterCounters[identifier] = 0
609
+ exitCounters[identifier] = 0
610
+
611
+ // Look up region info for the exit event payload
612
+ let region = monitoredRegions.first { $0.identifier == identifier }
393
613
  let params: [String: Any] = [
394
614
  "identifier": identifier,
395
- "uuid": beacon.uuid.uuidString.uppercased(),
396
- "major": beacon.major.intValue,
397
- "minor": beacon.minor.intValue,
615
+ "uuid": region?.uuid.uuidString.uppercased() ?? "",
616
+ "major": region?.major?.intValue ?? 0,
617
+ "minor": region?.minor?.intValue ?? 0,
398
618
  "event": "exit",
399
- "distance": beacon.accuracy
619
+ "distance": -1
400
620
  ]
401
621
  sendEvent("onBeaconExit", params)
402
622
  postBeaconNotification(identifier: identifier, eventType: "exit")
403
623
  }
404
624
  }
405
-
406
- // Also emit onBeaconFound if continuous scan is active for this beacon
407
- if continuousScanActive {
408
- let foundParams: [String: Any] = [
409
- "uuid": beacon.uuid.uuidString.uppercased(),
410
- "major": beacon.major.intValue,
411
- "minor": beacon.minor.intValue,
412
- "rssi": beacon.rssi,
413
- "distance": beacon.accuracy,
414
- "txPower": 0
415
- ]
416
- sendEvent("onBeaconFound", foundParams)
417
- }
418
625
  return
419
626
  }
420
627
 
@@ -435,7 +642,7 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
435
642
  }
436
643
  }
437
644
 
438
- public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
645
+ fileprivate func handleDidEnterRegion(_ region: CLRegion) {
439
646
  guard let beaconRegion = region as? CLBeaconRegion else { return }
440
647
  let identifier = beaconRegion.identifier
441
648
 
@@ -454,7 +661,7 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
454
661
  postBeaconNotification(identifier: identifier, eventType: "enter")
455
662
  }
456
663
 
457
- public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
664
+ fileprivate func handleDidExitRegion(_ region: CLRegion) {
458
665
  guard let beaconRegion = region as? CLBeaconRegion else { return }
459
666
  let identifier = beaconRegion.identifier
460
667
 
@@ -476,8 +683,90 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
476
683
  postBeaconNotification(identifier: identifier, eventType: "exit")
477
684
  }
478
685
 
479
- public func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
686
+ fileprivate func handleMonitoringDidFail(for region: CLRegion?, withError error: Error) {
480
687
  print("[ExpoBeacon] Monitoring failed for region \(region?.identifier ?? "unknown"): \(error.localizedDescription)")
481
688
  }
689
+
690
+ fileprivate func handleDidFailRanging(for constraint: CLBeaconIdentityConstraint, error: Error) {
691
+ print("[ExpoBeacon] Ranging failed for constraint \(constraint.uuid): \(error.localizedDescription)")
692
+
693
+ // If a one-shot scan is active and this constraint belongs to it, reject the promise
694
+ if scanPromise != nil && scanConstraints.contains(where: { constraintMatches($0, constraint) }) {
695
+ // Stop all scan constraints
696
+ for sc in scanConstraints {
697
+ locationManager.stopRangingBeacons(satisfying: sc)
698
+ }
699
+ scanConstraints.removeAll()
700
+ scannedBeacons.removeAll()
701
+ scanPromise?.reject("RANGING_FAILED", "Beacon ranging failed: \(error.localizedDescription)")
702
+ scanPromise = nil
703
+ }
704
+ }
705
+ }
706
+
707
+ // MARK: - CLLocationManagerDelegate
708
+
709
+ private class LocationDelegate: NSObject, CLLocationManagerDelegate {
710
+ private weak var module: ExpoBeaconModule?
711
+
712
+ init(module: ExpoBeaconModule) {
713
+ self.module = module
714
+ }
715
+
716
+ func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
717
+ module?.handleDidChangeAuthorization(status)
718
+ }
719
+
720
+ func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) {
721
+ module?.handleDidRange(beacons, satisfying: constraint)
722
+ }
723
+
724
+ func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
725
+ module?.handleDidEnterRegion(region)
726
+ }
727
+
728
+ func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
729
+ module?.handleDidExitRegion(region)
730
+ }
731
+
732
+ func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
733
+ module?.handleMonitoringDidFail(for: region, withError: error)
734
+ }
735
+
736
+ func locationManager(_ manager: CLLocationManager, didFailRangingFor beaconConstraint: CLBeaconIdentityConstraint, error: Error) {
737
+ module?.handleDidFailRanging(for: beaconConstraint, error: error)
738
+ }
739
+ }
740
+
741
+ // MARK: - CBCentralManagerDelegate (wildcard iBeacon scanning)
742
+
743
+ private class BluetoothDelegate: NSObject, CBCentralManagerDelegate {
744
+ private weak var module: ExpoBeaconModule?
745
+
746
+ init(module: ExpoBeaconModule) {
747
+ self.module = module
748
+ }
749
+
750
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
751
+ switch central.state {
752
+ case .poweredOn:
753
+ module?.beginWildcardScanning()
754
+ case .unauthorized:
755
+ module?.scanPromise?.reject("BLUETOOTH_UNAUTHORIZED", "Bluetooth permission denied")
756
+ module?.scanPromise = nil
757
+ case .poweredOff:
758
+ module?.scanPromise?.reject("BLUETOOTH_OFF", "Bluetooth is powered off")
759
+ module?.scanPromise = nil
760
+ default:
761
+ break
762
+ }
763
+ }
764
+
765
+ func centralManager(_ central: CBCentralManager,
766
+ didDiscover peripheral: CBPeripheral,
767
+ advertisementData: [String: Any],
768
+ rssi RSSI: NSNumber) {
769
+ module?.handleWildcardDiscovery(advertisementData: advertisementData, rssi: RSSI)
770
+ }
482
771
  }
483
772
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -27,16 +27,6 @@ export type BeaconRegionEvent = {
27
27
  distance: number;
28
28
  };
29
29
 
30
- /** Payload for ranging events (beacon proximity update). */
31
- export type BeaconRangingEvent = {
32
- identifier: string;
33
- uuid: string;
34
- major: number;
35
- minor: number;
36
- rssi: number;
37
- distance: number;
38
- };
39
-
40
30
  /** Payload for periodic distance update events during monitoring. */
41
31
  export type BeaconDistanceEvent = {
42
32
  identifier: string;
@@ -113,7 +103,6 @@ export type MonitoringOptions = {
113
103
  export type ExpoBeaconModuleEvents = {
114
104
  onBeaconEnter: (params: BeaconRegionEvent) => void;
115
105
  onBeaconExit: (params: BeaconRegionEvent) => void;
116
- onBeaconRanging: (params: BeaconRangingEvent) => void;
117
106
  onBeaconDistance: (params: BeaconDistanceEvent) => void;
118
107
  /** Fired continuously during a live scan as each beacon is detected. */
119
108
  onBeaconFound: (params: BeaconScanResult) => void;
@@ -10,10 +10,19 @@ import {
10
10
 
11
11
  declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
12
12
  /**
13
- * Start a one-shot BLE scan. Resolves with discovered beacons after scanDuration ms.
13
+ * Start a one-shot iBeacon scan. Resolves with discovered beacons after scanDuration ms.
14
+ *
15
+ * Pass one or more UUIDs to scan for specific beacons (uses CoreLocation on iOS).
16
+ * Pass an empty array or omit to perform a wildcard scan that discovers all nearby
17
+ * iBeacons (uses CoreBluetooth on iOS — foreground only).
18
+ *
19
+ * @param uuids Proximity UUIDs to filter by. Empty/omitted = wildcard scan.
14
20
  * @param scanDuration Duration in ms (default 5000)
15
21
  */
16
- scanForBeaconsAsync(scanDuration?: number): Promise<BeaconScanResult[]>;
22
+ scanForBeaconsAsync(
23
+ uuids?: string[],
24
+ scanDuration?: number,
25
+ ): Promise<BeaconScanResult[]>;
17
26
 
18
27
  /**
19
28
  * Register a beacon for persistent region monitoring.