expo-beacon 0.4.1 → 0.5.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.
Files changed (34) hide show
  1. package/README.md +854 -375
  2. package/android/build.gradle +1 -1
  3. package/android/src/main/AndroidManifest.xml +2 -1
  4. package/android/src/main/java/expo/modules/beacon/BeaconConstants.kt +26 -0
  5. package/android/src/main/java/expo/modules/beacon/BeaconEventReceiver.kt +5 -0
  6. package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +130 -116
  7. package/android/src/main/java/expo/modules/beacon/BeaconParsers.kt +33 -0
  8. package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +223 -115
  9. package/build/ExpoBeacon.types.d.ts +21 -2
  10. package/build/ExpoBeacon.types.d.ts.map +1 -1
  11. package/build/ExpoBeacon.types.js.map +1 -1
  12. package/build/ExpoBeaconModule.d.ts +5 -0
  13. package/build/ExpoBeaconModule.d.ts.map +1 -1
  14. package/build/ExpoBeaconModule.js.map +1 -1
  15. package/build/ExpoBeaconModule.web.d.ts +4 -0
  16. package/build/ExpoBeaconModule.web.d.ts.map +1 -1
  17. package/build/ExpoBeaconModule.web.js +4 -0
  18. package/build/ExpoBeaconModule.web.js.map +1 -1
  19. package/ios/ExpoBeaconModule.swift +394 -215
  20. package/package.json +1 -1
  21. package/src/ExpoBeacon.types.ts +21 -2
  22. package/src/ExpoBeaconModule.ts +6 -0
  23. package/src/ExpoBeaconModule.web.ts +4 -0
  24. package/build/ExpoBeaconView.d.ts +0 -2
  25. package/build/ExpoBeaconView.d.ts.map +0 -1
  26. package/build/ExpoBeaconView.js +0 -2
  27. package/build/ExpoBeaconView.js.map +0 -1
  28. package/build/ExpoBeaconView.web.d.ts +0 -2
  29. package/build/ExpoBeaconView.web.d.ts.map +0 -1
  30. package/build/ExpoBeaconView.web.js +0 -2
  31. package/build/ExpoBeaconView.web.js.map +0 -1
  32. package/ios/ExpoBeaconView.swift +0 -5
  33. package/src/ExpoBeaconView.tsx +0 -2
  34. package/src/ExpoBeaconView.web.tsx +0 -2
package/README.md CHANGED
@@ -1,18 +1,63 @@
1
1
  # expo-beacon
2
2
 
3
- An Expo module for scanning, pairing, and monitoring iBeacons and Eddystone beacons in React Native apps.
3
+ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddystone** beacons in React Native apps — with full background support on both iOS and Android.
4
+
5
+ | Feature | Description |
6
+ |---|---|
7
+ | **Scan** | Discover nearby iBeacons (one-shot or continuous) and Eddystone-UID / Eddystone-URL beacons via BLE |
8
+ | **Pair** | Register specific beacons for persistent tracking — survives app restarts |
9
+ | **Monitor** | Background enter/exit region detection with distance-based filtering |
10
+ | **Distance** | Real-time distance updates (~1/sec) while monitoring |
11
+ | **Notifications** | Automatic local notifications on region enter/exit, fully customisable |
12
+
13
+ | Platform | Native Implementation |
14
+ |---|---|
15
+ | **Android** | [AltBeacon](https://altbeacon.github.io/android-beacon-library/) library + Foreground Service |
16
+ | **iOS** | CoreLocation (iBeacon ranging & monitoring) + CoreBluetooth (Eddystone & wildcard BLE) |
17
+ | **Web** | Not supported (throws on all calls) |
4
18
 
5
- - **Scan** for nearby iBeacons and Eddystone beacons via one-shot or continuous BLE scans
6
- - **Pair** specific beacons for persistent tracking across app restarts
7
- - **Monitor** paired beacons in the background with enter/exit callbacks
8
- - **Distance events** fired continuously while a monitored beacon is in range
9
- - **Native notifications** shown automatically on region enter/exit
19
+ ---
10
20
 
11
- | Platform | Native implementation |
12
- | -------- | --------------------------------------------------------------------------------------------------------- |
13
- | Android | [AltBeacon](https://altbeacon.github.io/android-beacon-library/) (`org.altbeacon:android-beacon-library`) |
14
- | iOS | CoreLocation (UUID-targeted scans & monitoring) + CoreBluetooth (wildcard scanning & Eddystone) |
15
- | Web | Not supported (throws on all calls) |
21
+ ## Table of Contents
22
+
23
+ - [Installation](#installation)
24
+ - [Platform Setup](#platform-setup)
25
+ - [iOS](#ios)
26
+ - [Android](#android)
27
+ - [Quick Start](#quick-start)
28
+ - [Usage Examples](#usage-examples)
29
+ - [Scanning for iBeacons](#scanning-for-ibeacons)
30
+ - [Scanning for Eddystone Beacons](#scanning-for-eddystone-beacons)
31
+ - [Continuous (Live) Scanning](#continuous-live-scanning)
32
+ - [Pairing & Unpairing Beacons](#pairing--unpairing-beacons)
33
+ - [Background Monitoring](#background-monitoring)
34
+ - [Customizing Notifications](#customizing-notifications)
35
+ - [Cancelling a Scan](#cancelling-a-scan)
36
+ - [Full API Reference](#full-api-reference)
37
+ - [requestPermissionsAsync()](#requestpermissionsasync)
38
+ - [scanForBeaconsAsync()](#scanforbeaconsasyncuuids-scandurationms)
39
+ - [scanForEddystonesAsync()](#scanforeddystonesasyncscanDurationms)
40
+ - [startContinuousScan()](#startcontinuousscan)
41
+ - [stopContinuousScan()](#stopcontinuousscan)
42
+ - [cancelScan()](#cancelscan)
43
+ - [pairBeacon()](#pairbeaconidentifier-uuid-major-minor)
44
+ - [unpairBeacon()](#unpairbeaconidentifier)
45
+ - [getPairedBeacons()](#getpairedbeacons)
46
+ - [pairEddystone()](#paireddystoneidentifier-namespace-instance)
47
+ - [unpairEddystone()](#unpaireddystoneidentifier)
48
+ - [getPairedEddystones()](#getpairededdystones)
49
+ - [startMonitoring()](#startmonitoringoptions)
50
+ - [stopMonitoring()](#stopmonitoring)
51
+ - [setNotificationConfig()](#setnotificationconfigconfig)
52
+ - [Events](#events)
53
+ - [TypeScript Types](#typescript-types)
54
+ - [Background Behaviour](#background-behaviour)
55
+ - [Notifications](#notifications)
56
+ - [Platform-Specific Notes & Gotchas](#platform-specific-notes--gotchas)
57
+ - [Troubleshooting](#troubleshooting)
58
+ - [Error Codes](#error-codes)
59
+ - [Contributing](#contributing)
60
+ - [License](#license)
16
61
 
17
62
  ---
18
63
 
@@ -22,7 +67,7 @@ An Expo module for scanning, pairing, and monitoring iBeacons and Eddystone beac
22
67
  npx expo install expo-beacon
23
68
  ```
24
69
 
25
- > This module contains native code and **cannot** be used with Expo Go. Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/).
70
+ > **Important**: This module contains native code and **cannot** be used with Expo Go. You must use a [development build](https://docs.expo.dev/develop/development-builds/introduction/) or a bare workflow.
26
71
 
27
72
  ---
28
73
 
@@ -30,7 +75,9 @@ npx expo install expo-beacon
30
75
 
31
76
  ### iOS
32
77
 
33
- Add the following keys to your `Info.plist`:
78
+ #### 1. Info.plist Keys
79
+
80
+ Add the following keys to your `Info.plist` (or use an Expo config plugin):
34
81
 
35
82
  ```xml
36
83
  <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
@@ -41,47 +88,46 @@ Add the following keys to your `Info.plist`:
41
88
  <string>This app uses Bluetooth to scan for iBeacons.</string>
42
89
  ```
43
90
 
91
+ #### 2. Background Modes
92
+
44
93
  In Xcode under **Signing & Capabilities**, enable:
45
94
 
46
95
  - **Background Modes → Location updates**
47
96
  - **Background Modes → Uses Bluetooth LE accessories**
48
97
 
49
- > iOS limits apps to **20 simultaneously monitored regions** (iBeacon only — Eddystone beacons are monitored via BLE and do not count toward this limit).
50
- >
51
- > **iBeacon scanning on iOS**: Apple strips iBeacon manufacturer data from CoreBluetooth BLE advertisements, so wildcard iBeacon discovery is **not possible** on iOS. You must provide at least one proximity UUID, or pair beacons first (the module will use paired beacon UUIDs automatically). UUID-targeted scans use CoreLocation ranging and work in both foreground and background.
52
- >
53
- > **Eddystone scanning**: Eddystone beacons use standard BLE service data (UUID `0xFEAA`), which iOS does not filter. `scanForEddystonesAsync()` and continuous scanning discover Eddystones on both platforms without restrictions.
98
+ #### Key iOS Constraints
99
+
100
+ - **20 monitored regions max**: iOS limits `CLLocationManager` to 20 simultaneously monitored beacon regions. If you pair more than 20 iBeacons, only the first 20 are monitored. Eddystone beacons use BLE scanning and do **not** count toward this limit.
101
+ - **No wildcard iBeacon scanning**: Apple strips iBeacon manufacturer data from CoreBluetooth advertisements. You **must** supply at least one proximity UUID when scanning, or have paired beacons (the module auto-uses their UUIDs).
102
+ - **Eddystone works unrestricted**: Eddystone uses standard BLE service data (`0xFEAA`), which iOS does not strip. Both `scanForEddystonesAsync()` and continuous scanning discover Eddystones without restrictions.
54
103
 
55
104
  ### Android
56
105
 
57
- All required permissions are declared by the module's `AndroidManifest.xml` and merged into your app automatically. You must still request runtime permissions before scanning or monitoring — the easiest way is:
106
+ All required permissions are declared in the module's `AndroidManifest.xml` and merged automatically. You must still request **runtime permissions** before scanning or monitoring:
58
107
 
59
108
  ```ts
60
- await ExpoBeacon.requestPermissionsAsync();
109
+ const granted = await ExpoBeacon.requestPermissionsAsync();
61
110
  ```
62
111
 
112
+ The module requests: `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`, and `POST_NOTIFICATIONS` (API 33+).
113
+
63
114
  ---
64
115
 
65
- ## Quick-start example
116
+ ## Quick Start
117
+
118
+ A minimal example that pairs one iBeacon and one Eddystone, starts monitoring, and scans for nearby beacons:
66
119
 
67
120
  ```tsx
68
121
  import { useEffect, useState } from "react";
69
122
  import { Button, FlatList, Text, View } from "react-native";
70
123
  import ExpoBeacon from "expo-beacon";
71
- import type {
72
- BeaconScanResult,
73
- BeaconRegionEvent,
74
- BeaconDistanceEvent,
75
- EddystoneScanResult,
76
- EddystoneRegionEvent,
77
- EddystoneDistanceEvent,
78
- } from "expo-beacon";
124
+ import type { BeaconScanResult, BeaconRegionEvent } from "expo-beacon";
79
125
 
80
126
  export default function App() {
81
127
  const [beacons, setBeacons] = useState<BeaconScanResult[]>([]);
82
128
 
83
129
  useEffect(() => {
84
- // 1. Pair a known iBeacon for monitoring
130
+ // 1. Pair beacons you want to monitor
85
131
  ExpoBeacon.pairBeacon(
86
132
  "lobby-entrance",
87
133
  "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0",
@@ -89,76 +135,221 @@ export default function App() {
89
135
  100,
90
136
  );
91
137
 
92
- // 1b. Pair a known Eddystone-UID beacon for monitoring
93
- ExpoBeacon.pairEddystone(
94
- "meeting-room",
95
- "edd1ebeac04e5defa017", // 10-byte namespace
96
- "0123456789ab", // 6-byte instance
97
- );
98
-
99
- // 2. Subscribe to iBeacon region events
100
- const enterSub = ExpoBeacon.addListener(
101
- "onBeaconEnter",
102
- (e: BeaconRegionEvent) =>
103
- console.log(`Entered ${e.identifier} at ${e.distance.toFixed(1)} m`),
104
- );
105
- const exitSub = ExpoBeacon.addListener("onBeaconExit", (e: BeaconRegionEvent) =>
106
- console.log(`Exited ${e.identifier}`),
107
- );
108
- const distSub = ExpoBeacon.addListener(
109
- "onBeaconDistance",
110
- (e: BeaconDistanceEvent) =>
111
- console.log(`${e.identifier}: ${e.distance.toFixed(2)} m`),
112
- );
113
-
114
- // 3. Subscribe to Eddystone region events
115
- const eddyEnterSub = ExpoBeacon.addListener(
116
- "onEddystoneEnter",
117
- (e: EddystoneRegionEvent) =>
118
- console.log(`Eddystone entered ${e.identifier} (${e.namespace})`),
119
- );
120
- const eddyExitSub = ExpoBeacon.addListener(
121
- "onEddystoneExit",
122
- (e: EddystoneRegionEvent) =>
123
- console.log(`Eddystone exited ${e.identifier}`),
124
- );
125
- const eddyDistSub = ExpoBeacon.addListener(
126
- "onEddystoneDistance",
127
- (e: EddystoneDistanceEvent) =>
128
- console.log(`Eddystone ${e.identifier}: ${e.distance.toFixed(2)} m`),
129
- );
138
+ // 2. Listen for enter/exit events
139
+ const enterSub = ExpoBeacon.addListener("onBeaconEnter", (e: BeaconRegionEvent) => {
140
+ console.log(`Entered ${e.identifier} at ${e.distance.toFixed(1)} m`);
141
+ });
142
+ const exitSub = ExpoBeacon.addListener("onBeaconExit", (e: BeaconRegionEvent) => {
143
+ console.log(`Exited ${e.identifier}`);
144
+ });
130
145
 
131
- // 4. Start background monitoring (fires for both paired iBeacons and Eddystones)
146
+ // 3. Request permissions and start monitoring
132
147
  ExpoBeacon.requestPermissionsAsync().then((granted) => {
133
- if (granted) ExpoBeacon.startMonitoring(10); // enter events within 10 m
148
+ if (granted) ExpoBeacon.startMonitoring(10); // enter within 10 m
134
149
  });
135
150
 
136
151
  return () => {
137
152
  enterSub.remove();
138
153
  exitSub.remove();
139
- distSub.remove();
140
- eddyEnterSub.remove();
141
- eddyExitSub.remove();
142
- eddyDistSub.remove();
143
154
  ExpoBeacon.stopMonitoring();
144
155
  };
145
156
  }, []);
146
157
 
147
158
  async function scan() {
148
- // Wildcard scan discovers all nearby iBeacons (no UUID filter)
149
- const results = await ExpoBeacon.scanForBeaconsAsync([], 5000);
159
+ const results = await ExpoBeacon.scanForBeaconsAsync(
160
+ ["E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"],
161
+ 5000
162
+ );
150
163
  setBeacons(results);
151
164
  }
152
165
 
153
166
  return (
154
- <View>
167
+ <View style={{ flex: 1, padding: 20, paddingTop: 60 }}>
155
168
  <Button title="Scan 5 s" onPress={scan} />
156
169
  <FlatList
157
170
  data={beacons}
158
171
  keyExtractor={(b) => `${b.uuid}-${b.major}-${b.minor}`}
172
+ renderItem={({ item: b }) => (
173
+ <Text>{b.uuid} {b.major}/{b.minor} — {b.distance.toFixed(1)} m</Text>
174
+ )}
175
+ />
176
+ </View>
177
+ );
178
+ }
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Usage Examples
184
+
185
+ ### Scanning for iBeacons
186
+
187
+ #### One-shot scan with UUID filter (both platforms)
188
+
189
+ ```ts
190
+ import ExpoBeacon from "expo-beacon";
191
+
192
+ // Scan for 8 seconds, filtering by a specific UUID
193
+ const beacons = await ExpoBeacon.scanForBeaconsAsync(
194
+ ["E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"],
195
+ 8000,
196
+ );
197
+
198
+ beacons.forEach((b) => {
199
+ console.log(
200
+ `UUID: ${b.uuid} Major: ${b.major} Minor: ${b.minor} ` +
201
+ `Distance: ${b.distance.toFixed(1)}m RSSI: ${b.rssi}dBm`
202
+ );
203
+ });
204
+ ```
205
+
206
+ #### Wildcard scan (Android only)
207
+
208
+ ```ts
209
+ // Pass an empty array to discover ALL nearby iBeacons
210
+ // On iOS, this auto-uses UUIDs from paired beacons
211
+ const beacons = await ExpoBeacon.scanForBeaconsAsync([], 5000);
212
+ ```
213
+
214
+ #### Multiple UUID scan
215
+
216
+ ```ts
217
+ // Scan for beacons from two different manufacturers/deployments
218
+ const beacons = await ExpoBeacon.scanForBeaconsAsync(
219
+ [
220
+ "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0",
221
+ "FDA50693-A4E2-4FB1-AFCF-C6EB07647825",
222
+ ],
223
+ 10000,
224
+ );
225
+ ```
226
+
227
+ ---
228
+
229
+ ### Scanning for Eddystone Beacons
230
+
231
+ ```ts
232
+ import ExpoBeacon from "expo-beacon";
233
+
234
+ // Discover both Eddystone-UID and Eddystone-URL frames
235
+ const eddystones = await ExpoBeacon.scanForEddystonesAsync(5000);
236
+
237
+ eddystones.forEach((b) => {
238
+ if (b.frameType === "uid") {
239
+ console.log(`UID: namespace=${b.namespace} instance=${b.instance} dist=${b.distance.toFixed(1)}m`);
240
+ } else if (b.frameType === "url") {
241
+ console.log(`URL: ${b.url} dist=${b.distance.toFixed(1)}m`);
242
+ }
243
+ });
244
+ ```
245
+
246
+ > Eddystone scanning works identically on both iOS and Android — no UUID filter required.
247
+
248
+ ---
249
+
250
+ ### Continuous (Live) Scanning
251
+
252
+ Use continuous scanning when you need real-time beacon updates (e.g., a live radar UI). This fires events continuously rather than resolving a single promise.
253
+
254
+ ```tsx
255
+ import { useEffect, useRef, useState } from "react";
256
+ import { FlatList, Text, Button, View } from "react-native";
257
+ import ExpoBeacon from "expo-beacon";
258
+ import type { BeaconScanResult, EddystoneScanResult } from "expo-beacon";
259
+
260
+ export default function LiveScanner() {
261
+ const [ibeacons, setIbeacons] = useState<BeaconScanResult[]>([]);
262
+ const [eddystones, setEddystones] = useState<EddystoneScanResult[]>([]);
263
+ const [scanning, setScanning] = useState(false);
264
+ const subs = useRef<Array<{ remove: () => void }>>([]);
265
+
266
+ const startScan = () => {
267
+ setScanning(true);
268
+
269
+ // iBeacon advertisements
270
+ subs.current.push(
271
+ ExpoBeacon.addListener("onBeaconFound", (beacon) => {
272
+ setIbeacons((prev) => {
273
+ const key = `${beacon.uuid}-${beacon.major}-${beacon.minor}`;
274
+ const idx = prev.findIndex(
275
+ (b) => `${b.uuid}-${b.major}-${b.minor}` === key,
276
+ );
277
+ if (idx >= 0) {
278
+ const copy = [...prev];
279
+ copy[idx] = beacon; // Update distance/RSSI
280
+ return copy;
281
+ }
282
+ return [...prev, beacon];
283
+ });
284
+ }),
285
+ );
286
+
287
+ // Eddystone advertisements
288
+ subs.current.push(
289
+ ExpoBeacon.addListener("onEddystoneFound", (beacon) => {
290
+ setEddystones((prev) => {
291
+ const key = beacon.frameType === "uid"
292
+ ? `${beacon.namespace}-${beacon.instance}`
293
+ : `url-${beacon.url}`;
294
+ const idx = prev.findIndex((b) => {
295
+ const k = b.frameType === "uid"
296
+ ? `${b.namespace}-${b.instance}`
297
+ : `url-${b.url}`;
298
+ return k === key;
299
+ });
300
+ if (idx >= 0) {
301
+ const copy = [...prev];
302
+ copy[idx] = beacon;
303
+ return copy;
304
+ }
305
+ return [...prev, beacon];
306
+ });
307
+ }),
308
+ );
309
+
310
+ ExpoBeacon.startContinuousScan();
311
+ };
312
+
313
+ const stopScan = () => {
314
+ ExpoBeacon.stopContinuousScan();
315
+ subs.current.forEach((s) => s.remove());
316
+ subs.current = [];
317
+ setScanning(false);
318
+ };
319
+
320
+ useEffect(() => {
321
+ return () => stopScan(); // Cleanup on unmount
322
+ }, []);
323
+
324
+ return (
325
+ <View style={{ flex: 1, padding: 20 }}>
326
+ <Button
327
+ title={scanning ? "Stop Scan" : "Start Live Scan"}
328
+ onPress={scanning ? stopScan : startScan}
329
+ />
330
+ <Text style={{ fontWeight: "bold", marginTop: 10 }}>
331
+ iBeacons ({ibeacons.length})
332
+ </Text>
333
+ <FlatList
334
+ data={ibeacons}
335
+ keyExtractor={(b) => `${b.uuid}-${b.major}-${b.minor}`}
159
336
  renderItem={({ item: b }) => (
160
337
  <Text>
161
- {b.uuid} {b.major}/{b.minor} — {b.distance.toFixed(1)} m
338
+ {b.uuid.slice(0, 8)} {b.major}/{b.minor} — {b.distance.toFixed(1)}m (RSSI: {b.rssi})
339
+ </Text>
340
+ )}
341
+ />
342
+ <Text style={{ fontWeight: "bold", marginTop: 10 }}>
343
+ Eddystones ({eddystones.length})
344
+ </Text>
345
+ <FlatList
346
+ data={eddystones}
347
+ keyExtractor={(b, i) => `eddy-${i}`}
348
+ renderItem={({ item: b }) => (
349
+ <Text>
350
+ {b.frameType === "uid"
351
+ ? `UID: ${b.namespace?.slice(0, 8)}… / ${b.instance}`
352
+ : `URL: ${b.url}`} — {b.distance.toFixed(1)}m
162
353
  </Text>
163
354
  )}
164
355
  />
@@ -167,9 +358,225 @@ export default function App() {
167
358
  }
168
359
  ```
169
360
 
361
+ > **iOS note**: Continuous iBeacon scanning on iOS only discovers beacons whose UUID has been registered via `pairBeacon()`. On Android, all nearby BLE beacons are reported. Eddystone discovery works on both platforms regardless of pairing.
362
+
363
+ ---
364
+
365
+ ### Pairing & Unpairing Beacons
366
+
367
+ Pairing registers a beacon for persistent monitoring. Paired beacons survive app restarts — they are stored in `UserDefaults` (iOS) / `SharedPreferences` (Android).
368
+
369
+ ```ts
370
+ import ExpoBeacon from "expo-beacon";
371
+
372
+ // ── iBeacon ──
373
+
374
+ // Pair an iBeacon (identifier must be unique)
375
+ ExpoBeacon.pairBeacon(
376
+ "lobby-entrance", // your label
377
+ "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", // proximity UUID
378
+ 1, // major (0–65535)
379
+ 100, // minor (0–65535)
380
+ );
381
+
382
+ // Re-pairing with the same identifier replaces the previous entry
383
+ ExpoBeacon.pairBeacon(
384
+ "lobby-entrance",
385
+ "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0",
386
+ 1,
387
+ 200, // updated minor
388
+ );
389
+
390
+ // List all paired iBeacons
391
+ const paired = ExpoBeacon.getPairedBeacons();
392
+ console.log(paired);
393
+ // → [{ identifier: "lobby-entrance", uuid: "E2C5…", major: 1, minor: 200 }]
394
+
395
+ // Remove a beacon
396
+ ExpoBeacon.unpairBeacon("lobby-entrance");
397
+
398
+ // ── Eddystone-UID ──
399
+
400
+ // Pair an Eddystone-UID beacon
401
+ ExpoBeacon.pairEddystone(
402
+ "meeting-room", // your label
403
+ "edd1ebeac04e5defa017", // 10-byte namespace (20 hex chars)
404
+ "0123456789ab", // 6-byte instance (12 hex chars)
405
+ );
406
+
407
+ // List all paired Eddystones
408
+ const pairedEddy = ExpoBeacon.getPairedEddystones();
409
+ console.log(pairedEddy);
410
+ // → [{ identifier: "meeting-room", namespace: "edd1…", instance: "0123…" }]
411
+
412
+ // Remove an Eddystone
413
+ ExpoBeacon.unpairEddystone("meeting-room");
414
+ ```
415
+
416
+ ---
417
+
418
+ ### Background Monitoring
419
+
420
+ Monitoring watches all paired beacons (iBeacon + Eddystone) in the background and fires events when the device enters or exits a beacon region.
421
+
422
+ ```tsx
423
+ import { useEffect, useRef } from "react";
424
+ import ExpoBeacon from "expo-beacon";
425
+ import type {
426
+ BeaconRegionEvent,
427
+ BeaconDistanceEvent,
428
+ EddystoneRegionEvent,
429
+ EddystoneDistanceEvent,
430
+ } from "expo-beacon";
431
+
432
+ export function useBeaconMonitoring() {
433
+ const subs = useRef<Array<{ remove: () => void }>>([]);
434
+
435
+ useEffect(() => {
436
+ async function start() {
437
+ const granted = await ExpoBeacon.requestPermissionsAsync();
438
+ if (!granted) {
439
+ console.warn("Beacon permissions denied");
440
+ return;
441
+ }
442
+
443
+ // Subscribe to iBeacon events
444
+ subs.current.push(
445
+ ExpoBeacon.addListener("onBeaconEnter", (e: BeaconRegionEvent) => {
446
+ console.log(`[iBeacon] Entered "${e.identifier}" at ~${e.distance.toFixed(1)}m`);
447
+ }),
448
+ ExpoBeacon.addListener("onBeaconExit", (e: BeaconRegionEvent) => {
449
+ console.log(`[iBeacon] Exited "${e.identifier}"`);
450
+ }),
451
+ ExpoBeacon.addListener("onBeaconDistance", (e: BeaconDistanceEvent) => {
452
+ console.log(`[iBeacon] "${e.identifier}" → ${e.distance.toFixed(2)}m`);
453
+ }),
454
+ );
455
+
456
+ // Subscribe to Eddystone events
457
+ subs.current.push(
458
+ ExpoBeacon.addListener("onEddystoneEnter", (e: EddystoneRegionEvent) => {
459
+ console.log(`[Eddystone] Entered "${e.identifier}"`);
460
+ }),
461
+ ExpoBeacon.addListener("onEddystoneExit", (e: EddystoneRegionEvent) => {
462
+ console.log(`[Eddystone] Exited "${e.identifier}"`);
463
+ }),
464
+ ExpoBeacon.addListener("onEddystoneDistance", (e: EddystoneDistanceEvent) => {
465
+ console.log(`[Eddystone] "${e.identifier}" → ${e.distance.toFixed(2)}m`);
466
+ }),
467
+ );
468
+
469
+ // Start with distance threshold
470
+ await ExpoBeacon.startMonitoring({
471
+ maxDistance: 10, // Only fire "enter" within 10 metres
472
+ notifications: {
473
+ beaconEvents: {
474
+ enterTitle: "You're near a beacon!",
475
+ exitTitle: "Beacon out of range",
476
+ body: "{identifier} {event}ed",
477
+ },
478
+ },
479
+ });
480
+ }
481
+
482
+ start();
483
+
484
+ return () => {
485
+ subs.current.forEach((s) => s.remove());
486
+ subs.current = [];
487
+ ExpoBeacon.stopMonitoring();
488
+ };
489
+ }, []);
490
+ }
491
+ ```
492
+
493
+ #### Simple shorthand (number = maxDistance)
494
+
495
+ ```ts
496
+ // Equivalent to { maxDistance: 5 }
497
+ await ExpoBeacon.startMonitoring(5);
498
+ ```
499
+
500
+ #### Monitor with no distance filter
501
+
502
+ ```ts
503
+ // Monitor without distance limit — enter fires as soon as the region is detected
504
+ await ExpoBeacon.startMonitoring();
505
+ ```
506
+
507
+ ---
508
+
509
+ ### Customizing Notifications
510
+
511
+ #### Persistent configuration (survives app restarts)
512
+
513
+ ```ts
514
+ ExpoBeacon.setNotificationConfig({
515
+ // Enter/exit alert notifications (both platforms)
516
+ beaconEvents: {
517
+ enabled: true, // Set false to suppress notifications entirely
518
+ enterTitle: "Beacon nearby",
519
+ exitTitle: "Beacon out of range",
520
+ body: "{identifier} {event}ed", // Placeholders: {identifier}, {event}
521
+ sound: true, // iOS only
522
+ icon: "ic_beacon_notification", // Android only — drawable resource name
523
+ },
524
+
525
+ // Persistent status-bar notification (Android only)
526
+ foregroundService: {
527
+ title: "My App — Monitoring",
528
+ text: "Watching for nearby beacons",
529
+ icon: "ic_service",
530
+ },
531
+
532
+ // Android notification channel
533
+ channel: {
534
+ name: "Proximity Alerts",
535
+ description: "Alerts when beacons enter or leave range",
536
+ importance: "default", // "low" | "default" | "high"
537
+ },
538
+ });
539
+ ```
540
+
541
+ #### One-off session configuration (inline with startMonitoring)
542
+
543
+ ```ts
544
+ await ExpoBeacon.startMonitoring({
545
+ maxDistance: 5,
546
+ notifications: {
547
+ beaconEvents: { enabled: false }, // Silent monitoring — no user-facing alerts
548
+ },
549
+ });
550
+ ```
551
+
552
+ ---
553
+
554
+ ### Cancelling a Scan
555
+
556
+ Cancel any in-progress one-shot scan (iBeacon or Eddystone). The pending promise will reject with error code `SCAN_CANCELLED`.
557
+
558
+ ```ts
559
+ // Start a long scan
560
+ const scanPromise = ExpoBeacon.scanForBeaconsAsync(
561
+ ["E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"],
562
+ 30000,
563
+ );
564
+
565
+ // Cancel it after 2 seconds
566
+ setTimeout(() => ExpoBeacon.cancelScan(), 2000);
567
+
568
+ try {
569
+ const results = await scanPromise;
570
+ } catch (e) {
571
+ if (e.code === "SCAN_CANCELLED") {
572
+ console.log("Scan was cancelled by user");
573
+ }
574
+ }
575
+ ```
576
+
170
577
  ---
171
578
 
172
- ## API Reference
579
+ ## Full API Reference
173
580
 
174
581
  ### `requestPermissionsAsync()`
175
582
 
@@ -177,12 +584,14 @@ export default function App() {
177
584
  requestPermissionsAsync(): Promise<boolean>
178
585
  ```
179
586
 
180
- Requests all permissions required for scanning and monitoring:
587
+ Requests all permissions required for scanning and monitoring.
181
588
 
182
- - **Android**: `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS` (API 33+)
183
- - **iOS**: `CLLocationManager` "Always" authorization
589
+ | Platform | Permissions Requested |
590
+ |---|---|
591
+ | **Android** | `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS` (API 33+) |
592
+ | **iOS** | `CLLocationManager` "When In Use" → "Always" authorization (two-step prompt) |
184
593
 
185
- Returns `true` if all permissions were granted, `false` otherwise.
594
+ **Returns**: `true` if all required permissions were granted.
186
595
 
187
596
  ```ts
188
597
  const granted = await ExpoBeacon.requestPermissionsAsync();
@@ -191,6 +600,8 @@ if (!granted) {
191
600
  }
192
601
  ```
193
602
 
603
+ > **Tip**: Call this before `scanForBeaconsAsync()` or `startMonitoring()`. If you call `startMonitoring()` without prior authorization, it requests "Always" permission automatically, but explicit control gives a better UX.
604
+
194
605
  ---
195
606
 
196
607
  ### `scanForBeaconsAsync(uuids?, scanDurationMs?)`
@@ -199,37 +610,64 @@ if (!granted) {
199
610
  scanForBeaconsAsync(uuids?: string[], scanDurationMs?: number): Promise<BeaconScanResult[]>
200
611
  ```
201
612
 
202
- Starts a **one-shot BLE scan**, waits for `scanDurationMs` milliseconds, then resolves with all beacons discovered during that window.
613
+ Performs a **one-shot iBeacon scan**. Waits for the specified duration, then resolves with all discovered beacons.
203
614
 
204
- | Parameter | Type | Default | Description |
205
- | ---------------- | ---------- | ------- | ---------------------------------------- |
206
- | `uuids` | `string[]` | `[]` | Proximity UUIDs to filter by. **iOS**: at least one UUID is required (or have paired beacons). **Android**: pass `[]` for a wildcard scan. |
207
- | `scanDurationMs` | `number` | `5000` | How long to scan in milliseconds (1–60 000 recommended) |
615
+ | Parameter | Type | Default | Description |
616
+ |---|---|---|---|
617
+ | `uuids` | `string[]` | `[]` | Proximity UUIDs to filter by. See platform differences below. |
618
+ | `scanDurationMs` | `number` | `5000` | Scan duration in milliseconds (must be > 0). |
208
619
 
209
- Returns an array of [`BeaconScanResult`](#beaconscanresult) objects. Rejects with `SCAN_IN_PROGRESS` if another scan is already running.
620
+ **Returns**: `BeaconScanResult[]` deduplicated by UUID + major + minor.
210
621
 
211
- **Platform differences**
212
-
213
- | | Empty UUIDs (`[]`) | Targeted (`['UUID-1', …]`) |
622
+ | Behaviour | Android | iOS |
214
623
  |---|---|---|
215
- | **Android** | Discovers all iBeacons via AltBeacon | Filters results to matching UUIDs |
216
- | **iOS** | Uses paired beacon UUIDs automatically. Rejects if no UUIDs and no paired beacons. | CoreLocation ranging (works in foreground & background) |
624
+ | Empty `uuids` (`[]`) | Wildcard — discovers all nearby iBeacons | Auto-uses paired beacon UUIDs. Rejects with `WILDCARD_NOT_SUPPORTED` if none are paired. |
625
+ | Targeted (`["UUID-1"]`) | Filters scan results to matching UUIDs | CoreLocation ranging for those UUIDs |
626
+
627
+ **Possible errors**:
217
628
 
218
- > **iOS limitation**: Apple strips iBeacon manufacturer data from CoreBluetooth BLE advertisements. Wildcard iBeacon scanning (no UUID filter) is not possible on iOS. When you pass an empty `uuids` array, the module automatically uses UUIDs from your paired beacons. If no beacons are paired, the call rejects with `WILDCARD_NOT_SUPPORTED`. For Eddystone scanning, use `scanForEddystonesAsync()` instead — it works without restrictions on both platforms.
629
+ | Code | Reason |
630
+ |---|---|
631
+ | `SCAN_IN_PROGRESS` | Another scan is already running |
632
+ | `INVALID_UUID` | One of the UUID strings is malformed |
633
+ | `INVALID_DURATION` | Duration ≤ 0 |
634
+ | `PERMISSION_DENIED` | Location permission not granted |
635
+ | `WILDCARD_NOT_SUPPORTED` | iOS: empty UUIDs with no paired beacons |
636
+ | `SCAN_CANCELLED` | `cancelScan()` was called |
219
637
 
220
638
  ```ts
221
- // Scan by UUID — works on both platforms
222
- const filtered = await ExpoBeacon.scanForBeaconsAsync(
223
- ['E2C56DB5-DFFB-48D2-B060-D0F5A71096E0'],
639
+ const beacons = await ExpoBeacon.scanForBeaconsAsync(
640
+ ["E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"],
224
641
  8000,
225
642
  );
643
+ ```
226
644
 
227
- // Wildcard scan — Android only. On iOS, uses paired beacon UUIDs.
228
- const all = await ExpoBeacon.scanForBeaconsAsync([], 5000);
645
+ ---
229
646
 
230
- filtered.forEach((b) =>
231
- console.log(`${b.uuid} major=${b.major} minor=${b.minor} dist=${b.distance.toFixed(1)}m rssi=${b.rssi}dBm`)
232
- );
647
+ ### `scanForEddystonesAsync(scanDurationMs?)`
648
+
649
+ ```ts
650
+ scanForEddystonesAsync(scanDurationMs?: number): Promise<EddystoneScanResult[]>
651
+ ```
652
+
653
+ Performs a **one-shot Eddystone scan** using BLE. Discovers both Eddystone-UID and Eddystone-URL frames.
654
+
655
+ | Parameter | Type | Default | Description |
656
+ |---|---|---|---|
657
+ | `scanDurationMs` | `number` | `5000` | Scan duration in milliseconds (must be > 0). |
658
+
659
+ **Returns**: `EddystoneScanResult[]` — deduplicated by namespace:instance (UID) or url (URL).
660
+
661
+ **Possible errors**:
662
+
663
+ | Code | Reason |
664
+ |---|---|
665
+ | `SCAN_IN_PROGRESS` | Another Eddystone scan is already running |
666
+ | `INVALID_DURATION` | Duration ≤ 0 |
667
+ | `SCAN_CANCELLED` | `cancelScan()` was called |
668
+
669
+ ```ts
670
+ const eddystones = await ExpoBeacon.scanForEddystonesAsync(5000);
233
671
  ```
234
672
 
235
673
  ---
@@ -240,37 +678,33 @@ filtered.forEach((b) =>
240
678
  startContinuousScan(): void
241
679
  ```
242
680
 
243
- Begins a **continuous BLE scan** that fires an [`onBeaconFound`](#onbeaconfound) event every time a beacon advertisement is received. Call [`stopContinuousScan()`](#stopcontinuousscan) to end it.
681
+ Begins a **continuous BLE scan** that streams beacon discoveries via events:
682
+ - `onBeaconFound` — iBeacon advertisements
683
+ - `onEddystoneFound` — Eddystone advertisements
244
684
 
245
- Unlike `scanForBeaconsAsync`, this never resolvesit streams results in real time. Eddystone beacons are also reported via the [`onEddystoneFound`](#oneddystonefound) event.
685
+ Does not return results directlysubscribe to events before calling. Call `stopContinuousScan()` to end.
246
686
 
247
- **iOS note**: Due to CoreLocation API constraints, `startContinuousScan()` on iOS only ranges iBeacons that have been previously paired with `pairBeacon()`. On Android, all nearby BLE beacons are reported regardless of pairing status. Eddystone beacons are discovered on both platforms via CoreBluetooth / AltBeacon regardless of pairing.
687
+ > **iOS**: Only reports iBeacons whose UUID is registered via `pairBeacon()`. Eddystones are reported regardless of pairing.
248
688
 
249
- ```ts
250
- const sub = ExpoBeacon.addListener("onBeaconFound", (beacon) => {
251
- console.log("Live:", beacon.uuid, beacon.distance);
252
- });
253
- const eddySub = ExpoBeacon.addListener("onEddystoneFound", (beacon) => {
254
- console.log("Eddystone:", beacon.frameType, beacon.namespace ?? beacon.url);
255
- });
689
+ ---
256
690
 
257
- ExpoBeacon.startContinuousScan();
691
+ ### `stopContinuousScan()`
258
692
 
259
- // later, when done:
260
- ExpoBeacon.stopContinuousScan();
261
- sub.remove();
262
- eddySub.remove();
693
+ ```ts
694
+ stopContinuousScan(): void
263
695
  ```
264
696
 
697
+ Stops the continuous scan. No-op if no scan is running.
698
+
265
699
  ---
266
700
 
267
- ### `stopContinuousScan()`
701
+ ### `cancelScan()`
268
702
 
269
703
  ```ts
270
- stopContinuousScan(): void
704
+ cancelScan(): void
271
705
  ```
272
706
 
273
- Stops the continuous scan started by `startContinuousScan()`. No-op if no scan is running.
707
+ Cancels any in-progress one-shot scan (iBeacon or Eddystone). The pending promise rejects with code `SCAN_CANCELLED`.
274
708
 
275
709
  ---
276
710
 
@@ -280,22 +714,19 @@ Stops the continuous scan started by `startContinuousScan()`. No-op if no scan i
280
714
  pairBeacon(identifier: string, uuid: string, major: number, minor: number): void
281
715
  ```
282
716
 
283
- Registers a beacon for persistent region monitoring. Paired beacons survive app restarts (stored in `SharedPreferences` on Android, `UserDefaults` on iOS). Calling `pairBeacon` with an existing `identifier` replaces that entry.
717
+ Registers an iBeacon for persistent monitoring.
284
718
 
285
- | Parameter | Type | Description |
286
- | ------------ | -------- | ------------------------------------------------------------- |
287
- | `identifier` | `string` | Your unique label for this beacon (e.g. `"lobby-entrance"`) |
288
- | `uuid` | `string` | iBeacon proximity UUID (case-insensitive, standard format) |
289
- | `major` | `number` | iBeacon major value (0 – 65535) |
290
- | `minor` | `number` | iBeacon minor value (0 – 65535) |
719
+ | Parameter | Type | Description |
720
+ |---|---|---|
721
+ | `identifier` | `string` | Unique label (e.g. `"lobby-entrance"`). Re-using an identifier replaces the previous entry. |
722
+ | `uuid` | `string` | iBeacon proximity UUID (case-insensitive, e.g. `"E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"`) |
723
+ | `major` | `number` | Major value: `0`–`65535` |
724
+ | `minor` | `number` | Minor value: `0`–`65535` |
725
+
726
+ **Possible errors**: `INVALID_UUID`, `INVALID_MAJOR`, `INVALID_MINOR`.
291
727
 
292
728
  ```ts
293
- ExpoBeacon.pairBeacon(
294
- "main-door",
295
- "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0",
296
- 1,
297
- 42,
298
- );
729
+ ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42);
299
730
  ```
300
731
 
301
732
  ---
@@ -306,11 +737,11 @@ ExpoBeacon.pairBeacon(
306
737
  unpairBeacon(identifier: string): void
307
738
  ```
308
739
 
309
- Removes a previously paired beacon. If monitoring is active, the region for this beacon stops being monitored immediately.
740
+ Removes a paired iBeacon. If monitoring is active, the region stops being tracked immediately.
310
741
 
311
- | Parameter | Type | Description |
312
- | ------------ | -------- | ---------------------------------- |
313
- | `identifier` | `string` | The label used when pairing |
742
+ | Parameter | Type | Description |
743
+ |---|---|---|
744
+ | `identifier` | `string` | The label used when pairing |
314
745
 
315
746
  ```ts
316
747
  ExpoBeacon.unpairBeacon("main-door");
@@ -324,40 +755,11 @@ ExpoBeacon.unpairBeacon("main-door");
324
755
  getPairedBeacons(): PairedBeacon[]
325
756
  ```
326
757
 
327
- Returns the list of all currently paired beacons from persistent storage.
758
+ Returns all currently paired iBeacons from persistent storage.
328
759
 
329
760
  ```ts
330
761
  const paired = ExpoBeacon.getPairedBeacons();
331
- paired.forEach((b) =>
332
- console.log(b.identifier, b.uuid, b.major, b.minor)
333
- );
334
- ```
335
-
336
- ---
337
-
338
- ### `scanForEddystonesAsync(scanDurationMs?)`
339
-
340
- ```ts
341
- scanForEddystonesAsync(scanDurationMs?: number): Promise<EddystoneScanResult[]>
342
- ```
343
-
344
- Starts a **one-shot BLE scan** for Eddystone beacons, waits for `scanDurationMs` milliseconds, then resolves with all Eddystone beacons discovered.
345
-
346
- | Parameter | Type | Default | Description |
347
- | ---------------- | -------- | ------- | ---------------------------------------------- |
348
- | `scanDurationMs` | `number` | `5000` | How long to scan in milliseconds |
349
-
350
- Returns an array of [`EddystoneScanResult`](#eddystonescanresult) objects. Discovers both Eddystone-UID and Eddystone-URL frames.
351
-
352
- ```ts
353
- const eddystones = await ExpoBeacon.scanForEddystonesAsync(5000);
354
- eddystones.forEach((b) => {
355
- if (b.frameType === "uid") {
356
- console.log(`UID: ns=${b.namespace} inst=${b.instance} dist=${b.distance.toFixed(1)}m`);
357
- } else {
358
- console.log(`URL: ${b.url} dist=${b.distance.toFixed(1)}m`);
359
- }
360
- });
762
+ // [{ identifier: "main-door", uuid: "E2C5…", major: 1, minor: 42 }]
361
763
  ```
362
764
 
363
765
  ---
@@ -368,20 +770,18 @@ eddystones.forEach((b) => {
368
770
  pairEddystone(identifier: string, namespace: string, instance: string): void
369
771
  ```
370
772
 
371
- Registers an Eddystone-UID beacon for persistent region monitoring. Paired Eddystones survive app restarts (stored in `SharedPreferences` on Android, `UserDefaults` on iOS). Calling `pairEddystone` with an existing `identifier` replaces that entry.
773
+ Registers an Eddystone-UID beacon for persistent monitoring.
774
+
775
+ | Parameter | Type | Description |
776
+ |---|---|---|
777
+ | `identifier` | `string` | Unique label (e.g. `"meeting-room"`) |
778
+ | `namespace` | `string` | 10-byte namespace ID as hex string — must be exactly **20 hex characters** |
779
+ | `instance` | `string` | 6-byte instance ID as hex string — must be exactly **12 hex characters** |
372
780
 
373
- | Parameter | Type | Description |
374
- | ------------ | -------- | ---------------------------------------------------------------- |
375
- | `identifier` | `string` | Your unique label for this beacon (e.g. `"meeting-room"`) |
376
- | `namespace` | `string` | 10-byte namespace ID as a hex string (20 characters) |
377
- | `instance` | `string` | 6-byte instance ID as a hex string (12 characters) |
781
+ **Possible errors**: `INVALID_NAMESPACE`, `INVALID_INSTANCE`.
378
782
 
379
783
  ```ts
380
- ExpoBeacon.pairEddystone(
381
- "meeting-room",
382
- "edd1ebeac04e5defa017",
383
- "0123456789ab",
384
- );
784
+ ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab");
385
785
  ```
386
786
 
387
787
  ---
@@ -392,11 +792,11 @@ ExpoBeacon.pairEddystone(
392
792
  unpairEddystone(identifier: string): void
393
793
  ```
394
794
 
395
- Removes a previously paired Eddystone beacon.
795
+ Removes a paired Eddystone beacon.
396
796
 
397
- | Parameter | Type | Description |
398
- | ------------ | -------- | ---------------------------------- |
399
- | `identifier` | `string` | The label used when pairing |
797
+ | Parameter | Type | Description |
798
+ |---|---|---|
799
+ | `identifier` | `string` | The label used when pairing |
400
800
 
401
801
  ```ts
402
802
  ExpoBeacon.unpairEddystone("meeting-room");
@@ -410,13 +810,11 @@ ExpoBeacon.unpairEddystone("meeting-room");
410
810
  getPairedEddystones(): PairedEddystone[]
411
811
  ```
412
812
 
413
- Returns the list of all currently paired Eddystone beacons from persistent storage.
813
+ Returns all currently paired Eddystone beacons from persistent storage.
414
814
 
415
815
  ```ts
416
816
  const paired = ExpoBeacon.getPairedEddystones();
417
- paired.forEach((e) =>
418
- console.log(e.identifier, e.namespace, e.instance)
419
- );
817
+ // [{ identifier: "meeting-room", namespace: "edd1…", instance: "0123…" }]
420
818
  ```
421
819
 
422
820
  ---
@@ -427,43 +825,49 @@ paired.forEach((e) =>
427
825
  startMonitoring(options?: MonitoringOptions | number): Promise<void>
428
826
  ```
429
827
 
430
- Starts background region monitoring for all paired beacons (both iBeacons and Eddystones).
828
+ Starts background region monitoring for **all paired beacons** (iBeacon + Eddystone).
431
829
 
432
- Accepts either a `MonitoringOptions` object or a plain `number` (backward-compatible shorthand for `maxDistance`).
830
+ Accepts a `MonitoringOptions` object, a plain `number` (shorthand for `maxDistance`), or nothing.
433
831
 
434
- **`MonitoringOptions`**
832
+ | Property | Type | Default | Description |
833
+ |---|---|---|---|
834
+ | `maxDistance` | `number` | `undefined` | Distance threshold in metres. `onBeaconEnter` / `onEddystoneEnter` only fires when measured distance ≤ this value. `onBeaconExit` / `onEddystoneExit` always fires. Omit to disable filtering. |
835
+ | `exitDistance` | `number` | `maxDistance + min(maxDistance × 0.5, 2.5)` | Distance in metres at which exit events fire. Must be ≥ `maxDistance`. Creates a hysteresis band between enter and exit thresholds to prevent rapid toggling near the boundary. Only used when `maxDistance` is set. |
836
+ | `notifications` | `NotificationConfig` | `undefined` | Notification overrides for this session (persisted). |
435
837
 
436
- | Property | Type | Default | Description |
437
- | --------------- | -------------------- | ----------- | --------------------------------------------------------------------------------------------- |
438
- | `maxDistance` | `number` | `undefined` | Optional distance threshold in metres. `onBeaconEnter` is only emitted when the measured distance is ≤ this value. `onBeaconExit` is always emitted. Omit to disable distance filtering. |
439
- | `notifications` | `NotificationConfig` | `undefined` | Notification config overrides applied for this session. Persisted and takes effect immediately. |
838
+ **What happens on each platform**:
440
839
 
441
- **Android**: Launches `BeaconForegroundService` a persistent foreground service required by Android 8+ for background BLE. Restarts automatically after device reboot.
840
+ | Platform | Mechanism |
841
+ |---|---|
842
+ | **Android** | Starts `BeaconForegroundService` (persistent notification). Survives app backgrounding. Auto-restarts after device reboot via `BootReceiver`. Scan timing: 1.1 s every 5 s. |
843
+ | **iOS** | Activates `CLLocationManager` region monitoring (iBeacon) + CoreBluetooth BLE scanning (Eddystone). iOS can wake/relaunch the app on region boundary crossings, even if force-quit. |
442
844
 
443
- **iOS**: Activates `CLLocationManager` region monitoring. iOS can wake or relaunch the app when a region boundary is crossed, even if the app is terminated.
845
+ **Possible errors**: `PERMISSION_DENIED` (Always authorization required on iOS).
444
846
 
445
847
  ```ts
446
- // Backward-compatible shorthand (number = maxDistance)
848
+ // Shorthand just a distance threshold
447
849
  await ExpoBeacon.startMonitoring(5);
448
850
 
449
- // Full options object
851
+ // Full options with custom exit threshold
450
852
  await ExpoBeacon.startMonitoring({
451
- maxDistance: 5,
853
+ maxDistance: 10,
854
+ exitDistance: 15, // Exit fires when distance exceeds 15m
452
855
  notifications: {
453
856
  beaconEvents: {
454
- enterTitle: "Beacon nearby!",
455
- body: "{identifier} is within range",
857
+ enterTitle: "Welcome!",
858
+ body: "{identifier} is nearby",
456
859
  },
457
860
  },
458
861
  });
459
862
 
460
- // Monitor with no distance limit and no enter/exit notifications
863
+ // No distance filter, silent
461
864
  await ExpoBeacon.startMonitoring({
462
865
  notifications: { beaconEvents: { enabled: false } },
463
866
  });
464
- ```
465
867
 
466
- > Call `requestPermissionsAsync()` before `startMonitoring()`.
868
+ // No options at all — monitor all paired beacons, no distance filter, default notifications
869
+ await ExpoBeacon.startMonitoring();
870
+ ```
467
871
 
468
872
  ---
469
873
 
@@ -473,7 +877,7 @@ await ExpoBeacon.startMonitoring({
473
877
  stopMonitoring(): Promise<void>
474
878
  ```
475
879
 
476
- Stops background monitoring and removes all active region subscriptions. On Android, stops the foreground service.
880
+ Stops all background monitoring. On Android, stops the foreground service.
477
881
 
478
882
  ```ts
479
883
  await ExpoBeacon.stopMonitoring();
@@ -487,73 +891,81 @@ await ExpoBeacon.stopMonitoring();
487
891
  setNotificationConfig(config: NotificationConfig): void
488
892
  ```
489
893
 
490
- Persists notification configuration that is applied to all subsequent monitoring sessions. Values are stored in `SharedPreferences` (Android) / `UserDefaults` (iOS) and survive app restarts.
894
+ Persists notification configuration applied to **all subsequent monitoring sessions**. Survives app restarts.
895
+
896
+ For one-off overrides, pass `notifications` inside `startMonitoring(options)` instead.
491
897
 
492
- For one-off overrides tied to a single session, pass `notifications` directly in [`startMonitoring(options)`](#startmonitoringoptions) instead it calls this function automatically.
898
+ See [`NotificationConfig`](#notificationconfig) for the full shape.
493
899
 
494
900
  ```ts
495
901
  ExpoBeacon.setNotificationConfig({
496
- // Enter/exit alert notifications
497
902
  beaconEvents: {
498
- enabled: true, // false to suppress all enter/exit notifications
499
- enterTitle: "Beacon nearby",
500
- exitTitle: "Beacon out of range",
501
- body: "{identifier} {event}ed", // {identifier} and {event} are replaced at runtime
502
- sound: true, // iOS only — default true
503
- icon: "ic_beacon_notification", // Android only — drawable resource name
903
+ enabled: true,
904
+ enterTitle: "Nearby",
905
+ exitTitle: "Gone",
906
+ body: "{identifier} {event}ed",
907
+ sound: true,
908
+ icon: "ic_notification",
504
909
  },
505
-
506
- // Persistent status-bar notification while monitoring is active (Android only)
507
910
  foregroundService: {
508
- title: "My App is watching",
509
- text: "Monitoring for nearby beacons",
510
- icon: "ic_service", // Android drawable resource name
911
+ title: "Monitoring Active",
912
+ text: "Scanning for beacons",
913
+ icon: "ic_service",
511
914
  },
512
-
513
- // Android notification channel settings
514
915
  channel: {
515
- name: "Proximity Alerts",
516
- description: "Alerts when beacons enter or leave range",
517
- importance: "default", // "low" | "default" | "high"
916
+ name: "Beacons",
917
+ description: "Beacon proximity alerts",
918
+ importance: "default",
518
919
  },
519
920
  });
520
921
  ```
521
922
 
522
- > **Android channel importance note**: Android prevents decreasing channel importance once a user has been notified. Increasing importance always works; decreasing it will have no effect until the user clears the app's notification settings or reinstalls the app.
523
-
524
923
  ---
525
924
 
526
925
  ## Events
527
926
 
528
- Subscribe to events using `ExpoBeacon.addListener(eventName, handler)`. Always call `.remove()` on the subscription when your component unmounts.
927
+ Subscribe with `ExpoBeacon.addListener(eventName, handler)`. Always call `.remove()` on the returned subscription during cleanup.
529
928
 
530
929
  ```ts
531
930
  const sub = ExpoBeacon.addListener("onBeaconEnter", handler);
532
- // cleanup:
931
+ // Later:
533
932
  sub.remove();
534
933
  ```
535
934
 
536
- ---
935
+ ### Event Summary
537
936
 
538
- ### `onBeaconEnter`
937
+ | Event | Trigger | Payload Type |
938
+ |---|---|---|
939
+ | `onBeaconEnter` | Paired iBeacon enters range (respects `maxDistance`) | `BeaconRegionEvent` |
940
+ | `onBeaconExit` | Paired iBeacon leaves range (always fires) | `BeaconRegionEvent` |
941
+ | `onBeaconDistance` | Periodic distance update during monitoring (~1/sec) | `BeaconDistanceEvent` |
942
+ | `onBeaconFound` | iBeacon detected during continuous scan | `BeaconScanResult` |
943
+ | `onEddystoneFound` | Eddystone detected during continuous scan | `EddystoneScanResult` |
944
+ | `onEddystoneEnter` | Paired Eddystone enters range (respects `maxDistance`) | `EddystoneRegionEvent` |
945
+ | `onEddystoneExit` | Paired Eddystone leaves range (always fires) | `EddystoneRegionEvent` |
946
+ | `onEddystoneDistance` | Periodic Eddystone distance update during monitoring | `EddystoneDistanceEvent` |
539
947
 
540
- Fired when the device enters the region of a paired (monitored) beacon. If `maxDistance` was set in `startMonitoring`, this only fires when the measured distance at the time of entry is within that threshold.
948
+ ### Event Detail
541
949
 
542
- **Payload**: [`BeaconRegionEvent`](#beaconregionevent)
950
+ #### `onBeaconEnter`
951
+
952
+ Fired when the device enters the region of a paired iBeacon. If `maxDistance` was set, only fires when the measured distance is within the threshold.
543
953
 
544
954
  ```ts
545
955
  ExpoBeacon.addListener("onBeaconEnter", (e) => {
546
- console.log(`Entered "${e.identifier}" (${e.uuid}) at ~${e.distance.toFixed(1)} m`);
956
+ // e.identifier "lobby-entrance"
957
+ // e.uuid — "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
958
+ // e.major — 1
959
+ // e.minor — 100
960
+ // e.event — "enter"
961
+ // e.distance — 3.2 (metres, or –1 if unavailable)
962
+ console.log(`Entered "${e.identifier}" at ~${e.distance.toFixed(1)}m`);
547
963
  });
548
964
  ```
549
965
 
550
- ---
551
-
552
- ### `onBeaconExit`
553
-
554
- Fired when the device leaves the region of a monitored beacon. Always fired regardless of distance filtering.
966
+ #### `onBeaconExit`
555
967
 
556
- **Payload**: [`BeaconRegionEvent`](#beaconregionevent)
968
+ Fired when the device leaves the region. **Always fires** regardless of `maxDistance` setting.
557
969
 
558
970
  ```ts
559
971
  ExpoBeacon.addListener("onBeaconExit", (e) => {
@@ -561,97 +973,68 @@ ExpoBeacon.addListener("onBeaconExit", (e) => {
561
973
  });
562
974
  ```
563
975
 
564
- ---
565
-
566
- ### `onBeaconRanging`
567
-
568
- > **Not currently emitted.** This event is declared in the TypeScript types ([`BeaconRangingEvent`](#beaconrangingevent)) but is not fired by either the iOS or Android native implementation. Use [`onBeaconDistance`](#onbeacondistance) for periodic distance updates during monitoring.
569
-
570
- ---
571
-
572
- ### `onBeaconDistance`
573
-
574
- Fired continuously during monitoring whenever a distance update is received for a paired beacon. Useful for real-time proximity UI.
976
+ #### `onBeaconDistance`
575
977
 
576
- **Payload**: [`BeaconDistanceEvent`](#beacondistanceevent)
978
+ Fired continuously during monitoring with the latest distance reading. Useful for proximity-based UI.
577
979
 
578
980
  ```ts
579
981
  ExpoBeacon.addListener("onBeaconDistance", (e) => {
580
- setProximity(e.distance);
982
+ // e.identifier, e.uuid, e.major, e.minor, e.distance
983
+ updateProximityBar(e.identifier, e.distance);
581
984
  });
582
985
  ```
583
986
 
584
- ---
585
-
586
- ### `onBeaconFound`
587
-
588
- Fired during a **continuous scan** (started with `startContinuousScan()`) each time an iBeacon advertisement is received.
987
+ #### `onBeaconFound`
589
988
 
590
- **Payload**: [`BeaconScanResult`](#beaconscanresult)
989
+ Fired during `startContinuousScan()` each time an iBeacon advertisement is received.
591
990
 
592
991
  ```ts
593
992
  ExpoBeacon.addListener("onBeaconFound", (b) => {
594
- console.log(`Found ${b.uuid} ${b.major}/${b.minor} at ${b.distance.toFixed(1)} m`);
993
+ console.log(`${b.uuid} ${b.major}/${b.minor} ${b.distance.toFixed(1)}m RSSI: ${b.rssi}`);
595
994
  });
596
995
  ```
597
996
 
598
- ---
599
-
600
- ### `onEddystoneFound`
601
-
602
- Fired during a **continuous scan** (started with `startContinuousScan()`) each time an Eddystone advertisement is received.
997
+ #### `onEddystoneFound`
603
998
 
604
- **Payload**: [`EddystoneScanResult`](#eddystonescanresult)
999
+ Fired during `startContinuousScan()` each time an Eddystone advertisement is received.
605
1000
 
606
1001
  ```ts
607
1002
  ExpoBeacon.addListener("onEddystoneFound", (b) => {
608
1003
  if (b.frameType === "uid") {
609
- console.log(`Eddystone-UID: ${b.namespace}/${b.instance} at ${b.distance.toFixed(1)} m`);
1004
+ console.log(`UID: ${b.namespace}/${b.instance} ${b.distance.toFixed(1)}m`);
610
1005
  } else {
611
- console.log(`Eddystone-URL: ${b.url}`);
1006
+ console.log(`URL: ${b.url} — ${b.distance.toFixed(1)}m`);
612
1007
  }
613
1008
  });
614
1009
  ```
615
1010
 
616
- ---
617
-
618
- ### `onEddystoneEnter`
619
-
620
- Fired when a paired Eddystone-UID beacon enters range during monitoring. If `maxDistance` was set in `startMonitoring`, this only fires when the measured distance is within that threshold.
1011
+ #### `onEddystoneEnter`
621
1012
 
622
- **Payload**: [`EddystoneRegionEvent`](#eddystoneregionevent)
1013
+ Fired when a paired Eddystone-UID beacon enters range during monitoring.
623
1014
 
624
1015
  ```ts
625
1016
  ExpoBeacon.addListener("onEddystoneEnter", (e) => {
626
- console.log(`Eddystone entered "${e.identifier}" (ns: ${e.namespace}) at ~${e.distance.toFixed(1)} m`);
1017
+ console.log(`Eddystone "${e.identifier}" entered (ns: ${e.namespace})`);
627
1018
  });
628
1019
  ```
629
1020
 
630
- ---
631
-
632
- ### `onEddystoneExit`
633
-
634
- Fired when a paired Eddystone-UID beacon leaves range during monitoring.
1021
+ #### `onEddystoneExit`
635
1022
 
636
- **Payload**: [`EddystoneRegionEvent`](#eddystoneregionevent)
1023
+ Fired when a paired Eddystone-UID beacon leaves range.
637
1024
 
638
1025
  ```ts
639
1026
  ExpoBeacon.addListener("onEddystoneExit", (e) => {
640
- console.log(`Eddystone left "${e.identifier}"`);
1027
+ console.log(`Eddystone "${e.identifier}" exited`);
641
1028
  });
642
1029
  ```
643
1030
 
644
- ---
645
-
646
- ### `onEddystoneDistance`
647
-
648
- Fired continuously during monitoring whenever a distance update is received for a paired Eddystone beacon (~1 update/sec).
1031
+ #### `onEddystoneDistance`
649
1032
 
650
- **Payload**: [`EddystoneDistanceEvent`](#eddystonedistanceevent)
1033
+ Fired continuously during monitoring with the latest Eddystone distance reading.
651
1034
 
652
1035
  ```ts
653
1036
  ExpoBeacon.addListener("onEddystoneDistance", (e) => {
654
- console.log(`Eddystone ${e.identifier}: ${e.distance.toFixed(2)} m`);
1037
+ console.log(`Eddystone "${e.identifier}" ${e.distance.toFixed(2)}m`);
655
1038
  });
656
1039
  ```
657
1040
 
@@ -659,24 +1042,46 @@ ExpoBeacon.addListener("onEddystoneDistance", (e) => {
659
1042
 
660
1043
  ## TypeScript Types
661
1044
 
1045
+ All types are exported from the package:
1046
+
1047
+ ```ts
1048
+ import type {
1049
+ BeaconScanResult,
1050
+ PairedBeacon,
1051
+ BeaconRegionEvent,
1052
+ BeaconDistanceEvent,
1053
+ EddystoneFrameType,
1054
+ EddystoneScanResult,
1055
+ PairedEddystone,
1056
+ EddystoneRegionEvent,
1057
+ EddystoneDistanceEvent,
1058
+ ExpoBeaconModuleEvents,
1059
+ MonitoringOptions,
1060
+ NotificationConfig,
1061
+ BeaconNotificationConfig,
1062
+ ForegroundServiceConfig,
1063
+ NotificationChannelConfig,
1064
+ } from "expo-beacon";
1065
+ ```
1066
+
662
1067
  ### `BeaconScanResult`
663
1068
 
664
- Returned by `scanForBeaconsAsync` and used in `onBeaconFound` events.
1069
+ Returned by `scanForBeaconsAsync()` and `onBeaconFound`.
665
1070
 
666
1071
  ```ts
667
1072
  type BeaconScanResult = {
668
- uuid: string; // iBeacon proximity UUID (uppercase, formatted)
669
- major: number; // iBeacon major value (0–65535)
670
- minor: number; // iBeacon minor value (0–65535)
671
- rssi: number; // Signal strength in dBm (negative integer, e.g. –65)
672
- distance: number; // Estimated distance in metres (calculated from RSSI + txPower)
673
- txPower: number; // Calibrated TX power from the beacon advertisement
1073
+ uuid: string; // Proximity UUID, uppercase (e.g. "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0")
1074
+ major: number; // 0–65535
1075
+ minor: number; // 0–65535
1076
+ rssi: number; // Signal strength in dBm (negative, e.g. –65)
1077
+ distance: number; // Estimated distance in metres
1078
+ txPower: number; // Calibrated TX power from the advertisement
674
1079
  };
675
1080
  ```
676
1081
 
677
1082
  ### `PairedBeacon`
678
1083
 
679
- Returned by `getPairedBeacons`.
1084
+ Returned by `getPairedBeacons()`.
680
1085
 
681
1086
  ```ts
682
1087
  type PairedBeacon = {
@@ -689,7 +1094,7 @@ type PairedBeacon = {
689
1094
 
690
1095
  ### `BeaconRegionEvent`
691
1096
 
692
- Payload for `onBeaconEnter` and `onBeaconExit`.
1097
+ Payload for `onBeaconEnter` / `onBeaconExit`.
693
1098
 
694
1099
  ```ts
695
1100
  type BeaconRegionEvent = {
@@ -698,22 +1103,7 @@ type BeaconRegionEvent = {
698
1103
  major: number;
699
1104
  minor: number;
700
1105
  event: "enter" | "exit";
701
- distance: number; // Measured distance in metres at event time; –1 if unavailable
702
- };
703
- ```
704
-
705
- ### `BeaconRangingEvent`
706
-
707
- Payload type for the `onBeaconRanging` event. **Declared for future use — this event is not currently emitted by either platform.** Use [`BeaconDistanceEvent`](#beacondistanceevent) and [`onBeaconDistance`](#onbeacondistance) for real-time distance updates.
708
-
709
- ```ts
710
- type BeaconRangingEvent = {
711
- identifier: string;
712
- uuid: string;
713
- major: number;
714
- minor: number;
715
- rssi: number; // Signal strength in dBm
716
- distance: number; // Estimated distance in metres
1106
+ distance: number; // Metres at event time; –1 if unavailable
717
1107
  };
718
1108
  ```
719
1109
 
@@ -733,43 +1123,43 @@ type BeaconDistanceEvent = {
733
1123
 
734
1124
  ### `EddystoneScanResult`
735
1125
 
736
- Returned by `scanForEddystonesAsync` and used in `onEddystoneFound` events.
1126
+ Returned by `scanForEddystonesAsync()` and `onEddystoneFound`.
737
1127
 
738
1128
  ```ts
739
1129
  type EddystoneScanResult = {
740
1130
  frameType: "uid" | "url";
741
- namespace?: string; // 10-byte hex string (20 chars). Present for UID frames.
742
- instance?: string; // 6-byte hex string (12 chars). Present for UID frames.
1131
+ namespace?: string; // 20 hex chars. Present for UID frames.
1132
+ instance?: string; // 12 hex chars. Present for UID frames.
743
1133
  url?: string; // Decoded URL. Present for URL frames.
744
- rssi: number; // Signal strength in dBm
745
- distance: number; // Estimated distance in metres
746
- txPower: number; // Calibrated TX power
1134
+ rssi: number;
1135
+ distance: number;
1136
+ txPower: number;
747
1137
  };
748
1138
  ```
749
1139
 
750
1140
  ### `PairedEddystone`
751
1141
 
752
- Returned by `getPairedEddystones`.
1142
+ Returned by `getPairedEddystones()`.
753
1143
 
754
1144
  ```ts
755
1145
  type PairedEddystone = {
756
- identifier: string; // Your label
757
- namespace: string; // 10-byte hex string (20 chars)
758
- instance: string; // 6-byte hex string (12 chars)
1146
+ identifier: string;
1147
+ namespace: string; // 20 hex chars
1148
+ instance: string; // 12 hex chars
759
1149
  };
760
1150
  ```
761
1151
 
762
1152
  ### `EddystoneRegionEvent`
763
1153
 
764
- Payload for `onEddystoneEnter` and `onEddystoneExit`.
1154
+ Payload for `onEddystoneEnter` / `onEddystoneExit`.
765
1155
 
766
1156
  ```ts
767
1157
  type EddystoneRegionEvent = {
768
- identifier: string; // Matches PairedEddystone.identifier
1158
+ identifier: string;
769
1159
  namespace: string;
770
1160
  instance: string;
771
1161
  event: "enter" | "exit";
772
- distance: number; // Measured distance in metres at event time; –1 if unavailable
1162
+ distance: number; // Metres; –1 if unavailable
773
1163
  };
774
1164
  ```
775
1165
 
@@ -782,55 +1172,50 @@ type EddystoneDistanceEvent = {
782
1172
  identifier: string;
783
1173
  namespace: string;
784
1174
  instance: string;
785
- distance: number; // Estimated distance in metres
1175
+ distance: number;
786
1176
  };
787
1177
  ```
788
1178
 
789
- ---
790
-
791
1179
  ### `MonitoringOptions`
792
1180
 
793
- Passed to `startMonitoring(options)`.
1181
+ Passed to `startMonitoring()`.
794
1182
 
795
1183
  ```ts
796
1184
  type MonitoringOptions = {
797
- maxDistance?: number; // Distance threshold in metres for enter events
798
- notifications?: NotificationConfig; // Notification overrides for this session
1185
+ maxDistance?: number;
1186
+ exitDistance?: number;
1187
+ notifications?: NotificationConfig;
799
1188
  };
800
1189
  ```
801
1190
 
802
1191
  ### `NotificationConfig`
803
1192
 
804
- Top-level config object accepted by `setNotificationConfig()` and `startMonitoring({ notifications })`.
1193
+ Top-level notification configuration.
805
1194
 
806
1195
  ```ts
807
1196
  type NotificationConfig = {
808
- beaconEvents?: BeaconNotificationConfig;
809
- foregroundService?: ForegroundServiceConfig; // Android only
810
- channel?: NotificationChannelConfig; // Android only
1197
+ beaconEvents?: BeaconNotificationConfig; // Enter/exit alerts
1198
+ foregroundService?: ForegroundServiceConfig; // Android only — persistent status bar
1199
+ channel?: NotificationChannelConfig; // Android only — channel settings
811
1200
  };
812
1201
  ```
813
1202
 
814
1203
  ### `BeaconNotificationConfig`
815
1204
 
816
- Controls the enter/exit alert notifications.
817
-
818
1205
  ```ts
819
1206
  type BeaconNotificationConfig = {
820
- enabled?: boolean; // false to disable all enter/exit notifications. Default: true
1207
+ enabled?: boolean; // Default: true. Set false to suppress.
821
1208
  enterTitle?: string; // Default: "Beacon Entered"
822
1209
  exitTitle?: string; // Default: "Beacon Exited"
823
- body?: string; // Template {identifier} and {event} are replaced at runtime
824
- // Default: "{identifier} region {event}ed"
825
- sound?: boolean; // iOS only — play notification sound. Default: true
826
- icon?: string; // Android only drawable resource name (e.g. "ic_notification")
1210
+ body?: string; // Default: "{identifier} region {event}ed"
1211
+ // Supports {identifier} and {event} placeholders.
1212
+ sound?: boolean; // iOS only. Default: true
1213
+ icon?: string; // Android only. Drawable resource name.
827
1214
  };
828
1215
  ```
829
1216
 
830
1217
  ### `ForegroundServiceConfig`
831
1218
 
832
- Controls the persistent Android status-bar notification while monitoring is active.
833
-
834
1219
  ```ts
835
1220
  type ForegroundServiceConfig = {
836
1221
  title?: string; // Default: "Beacon Monitoring Active"
@@ -841,12 +1226,10 @@ type ForegroundServiceConfig = {
841
1226
 
842
1227
  ### `NotificationChannelConfig`
843
1228
 
844
- Controls the Android notification channel shown in system settings.
845
-
846
1229
  ```ts
847
1230
  type NotificationChannelConfig = {
848
- name?: string; // Default: "Beacon Monitoring"
849
- description?: string; // Default: "Used for background iBeacon region monitoring"
1231
+ name?: string; // Default: "Beacon Monitoring"
1232
+ description?: string; // Default: "Used for background iBeacon region monitoring"
850
1233
  importance?: "low" | "default" | "high"; // Default: "low"
851
1234
  };
852
1235
  ```
@@ -857,46 +1240,142 @@ type NotificationChannelConfig = {
857
1240
 
858
1241
  ### Android
859
1242
 
860
- `startMonitoring()` launches a **foreground service** (`BeaconForegroundService`) with a persistent notification titled _"Beacon Monitoring Active"_. This is required by Android 8+ (Oreo) to keep BLE scanning alive when the app is backgrounded. The service is automatically restarted after device reboot if monitoring was active at shutdown (via `BootReceiver`).
1243
+ `startMonitoring()` launches a **foreground service** (`BeaconForegroundService`) with a persistent notification. This is required by Android 8+ (Oreo) to keep BLE scanning alive in the background.
861
1244
 
862
- Default scan timing: 1.1 s scan window every 5 s.
1245
+ | Behaviour | Detail |
1246
+ |---|---|
1247
+ | Foreground service | Required for background BLE on Android 8+. Shows persistent notification. |
1248
+ | Reboot survival | `BootReceiver` auto-restarts monitoring after device reboot. |
1249
+ | Scan timing | 1.1 s scan window every 5 s (AltBeacon default). |
1250
+ | Battery | Low impact due to duty-cycled scanning. |
863
1251
 
864
1252
  ### iOS
865
1253
 
866
- `startMonitoring()` activates `CLLocationManager` **region monitoring** for paired iBeacons. iOS can wake or relaunch the app when the device crosses a region boundary, even if the app has been force-quit. `allowsBackgroundLocationUpdates` is `true` and `pausesLocationUpdatesAutomatically` is `false`.
1254
+ `startMonitoring()` activates `CLLocationManager` region monitoring for iBeacons and CoreBluetooth BLE scanning for Eddystones.
867
1255
 
868
- For paired Eddystone beacons, iOS uses **CoreBluetooth BLE scanning** during monitoring. BLE scanning works reliably in the foreground and while the app is backgrounded with `Uses Bluetooth LE accessories` background mode enabled. However, BLE scanning may be throttled or stopped by iOS when the app is suspended.
869
-
870
- > iOS limits apps to **20 simultaneously monitored regions** (iBeacon onlyEddystone monitoring does not count toward this limit).
1256
+ | Behaviour | Detail |
1257
+ |---|---|
1258
+ | Region monitoring | iOS wakes/relaunches the app on region boundary crossings even if force-quit. |
1259
+ | BLE scanning | Eddystones are monitored via CoreBluetooth. Works reliably in foreground; may be throttled when the app is suspended. |
1260
+ | Background modes | `allowsBackgroundLocationUpdates = true`, `pausesLocationUpdatesAutomatically = false` |
1261
+ | Region limit | 20 simultaneous `CLBeaconRegion` registrations max. Eddystones don't count. |
871
1262
 
872
1263
  ---
873
1264
 
874
1265
  ## Notifications
875
1266
 
876
- A local notification is posted for every beacon enter and exit event (both iBeacon and Eddystone). All notification settings can be customised via [`setNotificationConfig()`](#setnotificationconfigconfig) or inline in [`startMonitoring(options)`](#startmonitoringoptions).
1267
+ A local notification is posted automatically for every beacon enter/exit event (both iBeacon and Eddystone) during monitoring.
1268
+
1269
+ ### Default Values
1270
+
1271
+ | Property | Default |
1272
+ |---|---|
1273
+ | Enter title | `"Beacon Entered"` |
1274
+ | Exit title | `"Beacon Exited"` |
1275
+ | Body | `"{identifier} region {event}ed"` |
1276
+ | Sound (iOS) | `true` |
1277
+ | Icon (Android) | System `ic_dialog_info` |
1278
+ | Foreground service title | `"Beacon Monitoring Active"` |
1279
+ | Foreground service text | `"Monitoring for iBeacons in the background"` |
1280
+ | Channel name (Android) | `"Beacon Monitoring"` |
1281
+ | Channel importance (Android) | `"low"` |
1282
+
1283
+ ### Android Channel
1284
+
1285
+ Both the foreground service and enter/exit alerts share the channel ID `expo_beacon_channel`. The channel is recreated on each `onStartCommand`, so config changes take effect on the next monitoring start.
1286
+
1287
+ > **Android channel importance note**: Android prevents decreasing channel importance after the first notification. Increasing works; decreasing has no effect until the user clears notification settings or reinstalls the app.
1288
+
1289
+ ---
1290
+
1291
+ ## Platform-Specific Notes & Gotchas
1292
+
1293
+ ### iOS Native Insights (CoreLocation + CoreBluetooth)
1294
+
1295
+ 1. **iBeacon scanning requires UUIDs**: Apple's CoreBluetooth strips iBeacon manufacturer data from BLE advertisements. The module uses `CLLocationManager` ranging with `CLBeaconIdentityConstraint`, which requires known UUIDs. Wildcard iBeacon discovery is architecturally impossible on iOS.
1296
+
1297
+ 2. **Two-step location permission**: iOS requires requesting "When In Use" first, then upgrading to "Always". The module handles this automatically via a two-step flow in `requestPermissionsAsync()`.
1298
+
1299
+ 3. **20 region limit**: `CLLocationManager` enforces a hard limit of 20 monitored `CLBeaconRegion` regions across all apps. If your app pairs more than 20 iBeacons, only the first 20 will be actively monitored. Plan your beacon deployment accordingly.
1300
+
1301
+ 4. **Region monitoring vs. ranging**: Region monitoring (enter/exit) works indefinitely in the background. Ranging (distance updates) requires the app to be in the foreground or have an active background task. The module keeps ranging alive when background location mode is enabled.
1302
+
1303
+ 5. **Eddystone background limitations**: Eddystone monitoring uses CoreBluetooth, which iOS throttles in the background (longer scan intervals, delayed discovery). For critical Eddystone use cases, consider using significant location changes to periodically wake the app.
877
1304
 
878
- ### Defaults
1305
+ 6. **Hysteresis**: The module requires 3 consecutive readings inside/outside the distance threshold before emitting enter/exit events. This prevents jitter from RSSI fluctuations.
879
1306
 
880
- | Property | Default value |
881
- | ------------------------------ | ------------------------------------------------ |
882
- | Enter title | `"Beacon Entered"` |
883
- | Exit title | `"Beacon Exited"` |
884
- | Body | `"{identifier} region {event}ed"` |
885
- | Sound (iOS) | `true` |
886
- | Icon (Android) | System `ic_dialog_info` |
887
- | Foreground service title | `"Beacon Monitoring Active"` |
888
- | Foreground service text | `"Monitoring for iBeacons in the background"` |
889
- | Channel name (Android) | `"Beacon Monitoring"` |
890
- | Channel importance (Android) | `"low"` |
1307
+ ### Android Native Insights (AltBeacon + Foreground Service)
891
1308
 
892
- ### Channel IDs (Android)
1309
+ 1. **Foreground service is mandatory**: Android 8+ kills background BLE scans. The module uses `BeaconForegroundService` with a persistent notification. Users will see this notification while monitoring is active.
893
1310
 
894
- | Channel / type | Importance |
895
- | ------------------------------- | ------------------- |
896
- | Foreground service (Android) | configurable (default `low`) |
897
- | Enter / exit alerts | configurable (default `default`) |
1311
+ 2. **Doze mode**: Android Doze can delay BLE scan callbacks. The foreground service mitigates this, but very aggressive OEM battery optimization (Xiaomi, Huawei, Samsung) may still interfere. Direct users to disable battery optimization for your app.
898
1312
 
899
- Both notifications share the channel id `expo_beacon_channel`. The channel is recreated on each `onStartCommand` so config changes take effect on the next monitoring start.
1313
+ 3. **Boot receiver**: Monitoring auto-restarts after reboot via `BootReceiver` reading the `is_monitoring` flag from `SharedPreferences`.
1314
+
1315
+ 4. **Runtime permissions**: Android 12+ requires `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` in addition to location. Android 13+ requires `POST_NOTIFICATIONS` for the foreground service notification. `requestPermissionsAsync()` handles all of these.
1316
+
1317
+ 5. **Notification channel immutability**: Once Android creates a notification channel with a given importance level, decreasing the importance has no effect. The only workaround is uninstalling and reinstalling the app.
1318
+
1319
+ ---
1320
+
1321
+ ## Troubleshooting
1322
+
1323
+ ### "WILDCARD_NOT_SUPPORTED" error on iOS
1324
+
1325
+ You called `scanForBeaconsAsync([])` with no paired beacons. Either:
1326
+ - Pass at least one UUID: `scanForBeaconsAsync(["YOUR-UUID"])`
1327
+ - Or pair beacons first with `pairBeacon()` — the module will auto-use their UUIDs
1328
+
1329
+ ### Scanning returns empty results
1330
+
1331
+ 1. Verify Bluetooth is enabled on the device
1332
+ 2. Ensure you called `requestPermissionsAsync()` and got `true`
1333
+ 3. On iOS, confirm you passed a valid UUID or have paired beacons
1334
+ 4. The beacon must be powered on, advertising, and within BLE range (~30–70 m typical)
1335
+ 5. Try a longer scan duration (10000 ms)
1336
+
1337
+ ### Monitoring events not firing
1338
+
1339
+ 1. Ensure beacons are paired **before** calling `startMonitoring()`
1340
+ 2. Check that permissions returned `true` (iOS needs "Always" authorization for background monitoring)
1341
+ 3. On iOS, verify Background Modes are enabled in Xcode
1342
+ 4. On Android, check that battery optimization is disabled for your app
1343
+ 5. If using `maxDistance`, the beacon may be too far — try removing the distance filter
1344
+
1345
+ ### Distance values are inaccurate
1346
+
1347
+ BLE distance estimation is inherently imprecise. RSSI fluctuates due to:
1348
+ - Physical obstacles (walls, furniture, the user's body)
1349
+ - Multipath interference
1350
+ - Device orientation
1351
+ - Other 2.4 GHz interference (Wi-Fi, microwaves)
1352
+
1353
+ Use distance values as approximate zones (immediate/near/far) rather than precise measurements. For best accuracy, calibrate `txPower` on your beacons at 1 metre.
1354
+
1355
+ ### Android foreground notification won't go away
1356
+
1357
+ The persistent notification is required by Android 8+ for background BLE scanning. It disappears when you call `stopMonitoring()`. You can customize its appearance via `setNotificationConfig()`.
1358
+
1359
+ ### `onBeaconEnter` fires repeatedly
1360
+
1361
+ The module uses hysteresis (3 consecutive readings) to prevent jitter. If you're still seeing repeated events, it may be because the beacon is at the boundary of `maxDistance`. Consider adding a margin to your distance threshold.
1362
+
1363
+ ---
1364
+
1365
+ ## Error Codes
1366
+
1367
+ | Code | Method | Description |
1368
+ |---|---|---|
1369
+ | `SCAN_IN_PROGRESS` | `scanForBeaconsAsync`, `scanForEddystonesAsync` | A scan is already running. Wait for it to complete or call `cancelScan()`. |
1370
+ | `SCAN_CANCELLED` | `scanForBeaconsAsync`, `scanForEddystonesAsync` | The scan was cancelled via `cancelScan()`. |
1371
+ | `INVALID_UUID` | `scanForBeaconsAsync`, `pairBeacon` | Malformed UUID string. |
1372
+ | `INVALID_DURATION` | `scanForBeaconsAsync`, `scanForEddystonesAsync` | Scan duration must be > 0. |
1373
+ | `INVALID_MAJOR` | `pairBeacon` | Major value not in range 0–65535. |
1374
+ | `INVALID_MINOR` | `pairBeacon` | Minor value not in range 0–65535. |
1375
+ | `INVALID_NAMESPACE` | `pairEddystone` | Namespace must be exactly 20 hex characters. |
1376
+ | `INVALID_INSTANCE` | `pairEddystone` | Instance must be exactly 12 hex characters. |
1377
+ | `PERMISSION_DENIED` | `scanForBeaconsAsync`, `startMonitoring` | Required permissions were not granted. |
1378
+ | `WILDCARD_NOT_SUPPORTED` | `scanForBeaconsAsync` | iOS only: no UUIDs provided and no paired beacons exist. |
900
1379
 
901
1380
  ---
902
1381