expo-beacon 0.9.3 → 0.10.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 (60) hide show
  1. package/README.md +276 -59
  2. package/android/src/main/AndroidManifest.xml +0 -2
  3. package/android/src/main/java/expo/modules/beacon/BeaconApiForwarder.kt +44 -31
  4. package/android/src/main/java/expo/modules/beacon/BeaconConstants.kt +20 -0
  5. package/android/src/main/java/expo/modules/beacon/BeaconEventLogger.kt +2 -1
  6. package/android/src/main/java/expo/modules/beacon/BeaconEventReceiver.kt +34 -88
  7. package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +257 -264
  8. package/android/src/main/java/expo/modules/beacon/BootReceiver.kt +20 -8
  9. package/android/src/main/java/expo/modules/beacon/CarPlayMonitor.kt +17 -9
  10. package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +222 -223
  11. package/build/ExpoBeacon.types.d.ts +19 -18
  12. package/build/ExpoBeacon.types.d.ts.map +1 -1
  13. package/build/ExpoBeacon.types.js.map +1 -1
  14. package/build/ExpoBeaconModule.d.ts +35 -8
  15. package/build/ExpoBeaconModule.d.ts.map +1 -1
  16. package/build/ExpoBeaconModule.js +4 -0
  17. package/build/ExpoBeaconModule.js.map +1 -1
  18. package/build/ExpoBeaconModule.web.d.ts +2 -41
  19. package/build/ExpoBeaconModule.web.d.ts.map +1 -1
  20. package/build/ExpoBeaconModule.web.js +18 -5
  21. package/build/ExpoBeaconModule.web.js.map +1 -1
  22. package/build/hooks/useBeacon.d.ts +132 -0
  23. package/build/hooks/useBeacon.d.ts.map +1 -0
  24. package/build/hooks/useBeacon.js +268 -0
  25. package/build/hooks/useBeacon.js.map +1 -0
  26. package/build/hooks/useCarPlay.d.ts +46 -0
  27. package/build/hooks/useCarPlay.d.ts.map +1 -0
  28. package/build/hooks/useCarPlay.js +88 -0
  29. package/build/hooks/useCarPlay.js.map +1 -0
  30. package/build/index.d.ts +5 -1
  31. package/build/index.d.ts.map +1 -1
  32. package/build/index.js +3 -0
  33. package/build/index.js.map +1 -1
  34. package/ios/BeaconApiForwarder.swift +6 -9
  35. package/ios/BluetoothDelegate.swift +2 -4
  36. package/ios/CarPlayMonitor.swift +26 -22
  37. package/ios/ExpoBeaconConstants.swift +25 -0
  38. package/ios/ExpoBeaconModule+Eddystone.swift +30 -14
  39. package/ios/ExpoBeaconModule+EventLogging.swift +7 -0
  40. package/ios/ExpoBeaconModule+Monitoring.swift +117 -6
  41. package/ios/ExpoBeaconModule+Permissions.swift +13 -33
  42. package/ios/ExpoBeaconModule+Scanning.swift +80 -9
  43. package/ios/ExpoBeaconModule+Storage.swift +66 -3
  44. package/ios/ExpoBeaconModule+Timers.swift +135 -110
  45. package/ios/ExpoBeaconModule.swift +242 -362
  46. package/package.json +1 -1
  47. package/plugin/build/index.d.ts.map +1 -1
  48. package/plugin/build/index.js +5 -13
  49. package/plugin/build/withBeaconAndroid.d.ts +8 -0
  50. package/plugin/build/withBeaconAndroid.d.ts.map +1 -1
  51. package/plugin/build/withBeaconAndroid.js +26 -6
  52. package/plugin/build/withBeaconIOS.d.ts +8 -0
  53. package/plugin/build/withBeaconIOS.d.ts.map +1 -1
  54. package/plugin/build/withBeaconIOS.js +32 -5
  55. package/src/ExpoBeacon.types.ts +21 -20
  56. package/src/ExpoBeaconModule.ts +39 -8
  57. package/src/ExpoBeaconModule.web.ts +86 -67
  58. package/src/hooks/useBeacon.ts +511 -0
  59. package/src/hooks/useCarPlay.ts +146 -0
  60. package/src/index.ts +14 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddyston
8
8
  | **Pair** | Register specific beacons for persistent tracking — survives app restarts |
9
9
  | **Monitor** | Background enter/exit region detection with distance-based filtering |
10
10
  | **Distance** | Real-time distance updates (~1/sec) while monitoring |
11
- | **Timeout** | Fire a one-shot event after a beacon stays in range for a configured duration |
11
+ | **Timeout** | Fire a one-shot event after a beacon has been out of range for a configured duration |
12
12
  | **Event Logging** | Persist every beacon event to a local SQLite database for diagnostics & replay |
13
13
  | **Notifications** | Automatic local notifications on region enter/exit, fully customisable |
14
14
 
@@ -16,7 +16,7 @@ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddyston
16
16
  |---|---|
17
17
  | **Android** | [AltBeacon](https://altbeacon.github.io/android-beacon-library/) library + Foreground Service |
18
18
  | **iOS** | CoreLocation (iBeacon ranging & monitoring) + CoreBluetooth (Eddystone & wildcard BLE) |
19
- | **Web** | Not supported (throws on all calls) |
19
+ | **Web** | Not supported (async methods reject, sync getters return inert defaults, everything else throws) |
20
20
 
21
21
  ---
22
22
 
@@ -27,6 +27,9 @@ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddyston
27
27
  - [iOS](#ios)
28
28
  - [Android](#android)
29
29
  - [Quick Start](#quick-start)
30
+ - [React Hooks](#react-hooks)
31
+ - [useBeacon()](#usebeacon)
32
+ - [useCarPlay()](#usecarplay)
30
33
  - [Usage Examples](#usage-examples)
31
34
  - [Scanning for iBeacons](#scanning-for-ibeacons)
32
35
  - [Scanning for Eddystone Beacons](#scanning-for-eddystone-beacons)
@@ -61,6 +64,7 @@ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddyston
61
64
  - [getEventLogs()](#geteventlogsoptions)
62
65
  - [clearEventLogs()](#cleareventlogs)
63
66
  - [destroyEventLogs()](#destroyeventlogs)
67
+ - [setApiEndpoint()](#setapiendpointurl-apikey-id)
64
68
  - [Events](#events)
65
69
  - [TypeScript Types](#typescript-types)
66
70
  - [Native Integrations](#native-integrations)
@@ -109,6 +113,8 @@ In Xcode under **Signing & Capabilities**, enable:
109
113
  - **Background Modes → Location updates**
110
114
  - **Background Modes → Uses Bluetooth LE accessories**
111
115
 
116
+ > When the bundled config plugin is installed (`"plugins": ["expo-beacon"]`), `location` is merged into `UIBackgroundModes` automatically on `expo prebuild` — the native module only enables background ranging when this mode is present.
117
+
112
118
  #### Key iOS Constraints
113
119
 
114
120
  - **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.
@@ -194,6 +200,133 @@ export default function App() {
194
200
 
195
201
  ---
196
202
 
203
+ ## React Hooks
204
+
205
+ For React / React Native apps the package ships two hooks that wrap the
206
+ imperative API, manage event subscriptions (with automatic cleanup), and expose
207
+ the relevant state reactively. Import them directly from the package:
208
+
209
+ ```ts
210
+ import { useBeacon, useCarPlay } from "expo-beacon";
211
+ ```
212
+
213
+ Both hooks accept optional event callbacks. Callbacks are read from a ref, so
214
+ passing fresh inline functions on every render does **not** re-subscribe the
215
+ underlying native listeners.
216
+
217
+ ### useBeacon()
218
+
219
+ Manages scanning and background monitoring. It keeps the paired-beacon lists,
220
+ the set of beacons currently in range, and the monitoring flag in sync, and
221
+ returns stable action wrappers.
222
+
223
+ ```tsx
224
+ import { useBeacon } from "expo-beacon";
225
+
226
+ function BeaconScreen() {
227
+ const {
228
+ inRange,
229
+ isMonitoring,
230
+ pairedBeacons,
231
+ requestPermissions,
232
+ pairBeacon,
233
+ startMonitoring,
234
+ stopMonitoring,
235
+ } = useBeacon({
236
+ onBeaconEnter: (e) => console.log("entered", e.identifier, e.distance),
237
+ onBeaconExit: (e) => console.log("exited", e.identifier),
238
+ onError: (e) => console.warn(`[${e.code}] ${e.message}`),
239
+ });
240
+
241
+ return (
242
+ <View>
243
+ <Button title="Grant permissions" onPress={requestPermissions} />
244
+ <Button
245
+ title={isMonitoring ? "Stop monitoring" : "Start monitoring"}
246
+ onPress={() => (isMonitoring ? stopMonitoring() : startMonitoring())}
247
+ />
248
+ {inRange.map((b) => (
249
+ <Text key={b.identifier}>
250
+ {b.identifier} — {b.distance >= 0 ? `${b.distance.toFixed(1)}m` : "n/a"}
251
+ </Text>
252
+ ))}
253
+ </View>
254
+ );
255
+ }
256
+ ```
257
+
258
+ | Returned value | Description |
259
+ | --- | --- |
260
+ | `pairedBeacons` / `pairedEddystones` | Reactive lists of paired devices. |
261
+ | `inRange` | Paired beacons currently in range, derived live from enter/exit/distance/timeout events (`InRangeBeacon[]`). |
262
+ | `isMonitoring` | Whether background monitoring is active. |
263
+ | `isEventLoggingEnabled` | Whether SQLite event logging is enabled (kept in sync by the logging actions). |
264
+ | `refreshPaired()` | Re-read the paired lists from native. |
265
+ | `pairBeacon()` / `unpairBeacon()` | Pair / unpair an iBeacon, then refresh. |
266
+ | `pairEddystone()` / `unpairEddystone()` | Pair / unpair an Eddystone, then refresh. |
267
+ | `scanForBeacons()` / `scanForEddystones()` | One-shot scans returning a promise. |
268
+ | `startContinuousScan()` / `stopContinuousScan()` | Live scan; results arrive via `onBeaconFound` / `onEddystoneFound`. |
269
+ | `cancelScan()` | Cancel an in-progress one-shot scan. |
270
+ | `startMonitoring()` / `stopMonitoring()` | Start / stop background monitoring. |
271
+ | `getMonitoringConfig()` | Read the current monitoring config + active-state snapshot. |
272
+ | `getMonitoredDeviceState()` / `getMonitoredDeviceStates()` | Native state snapshot for one / all paired devices. |
273
+ | `setNotificationConfig()` | Persist notification configuration for monitoring sessions. |
274
+ | `enableEventLogging()` / `disableEventLogging()` | Toggle SQLite logging (updates `isEventLoggingEnabled`). |
275
+ | `getEventLogs()` / `clearEventLogs()` / `destroyEventLogs()` | Read / clear / drop the persisted event log. |
276
+ | `setApiEndpoint()` / `getApiEndpoint()` | Configure / read the native event-forwarding endpoint. |
277
+ | `isBatteryOptimizationExempt()` / `requestBatteryOptimizationExemption()` | Check / request Android battery-optimization exemption. |
278
+ | `requestPermissions()` | Request the permissions needed for scanning / monitoring. |
279
+
280
+ `inRange` reflects **monitored (paired)** beacons only. Continuous-scan results
281
+ are delivered through the `onBeaconFound` / `onEddystoneFound` callbacks because
282
+ raw scan hits carry no paired identifier. Pass `track: false` to skip `inRange`
283
+ bookkeeping when you only need the callbacks.
284
+
285
+ ### useCarPlay()
286
+
287
+ Observes CarPlay / Android Auto connection state. It initializes from the
288
+ persisted native state on mount and tracks live connect / disconnect events.
289
+
290
+ ```tsx
291
+ import { useCarPlay } from "expo-beacon";
292
+
293
+ function CarPlayBadge() {
294
+ const { connected, transport, isMonitoring, startMonitoring, stopMonitoring } =
295
+ useCarPlay({
296
+ onConnected: (e) => console.log("car connected via", e.transport),
297
+ onDisconnected: () => console.log("car disconnected"),
298
+ });
299
+
300
+ return (
301
+ <View>
302
+ <Text>{connected ? `Connected (${transport})` : "Not connected"}</Text>
303
+ <Button
304
+ title={isMonitoring ? "Stop" : "Start"}
305
+ onPress={() => (isMonitoring ? stopMonitoring() : startMonitoring())}
306
+ />
307
+ </View>
308
+ );
309
+ }
310
+ ```
311
+
312
+ | Returned value | Description |
313
+ | --- | --- |
314
+ | `connected` | Whether a CarPlay / Android Auto session is active. |
315
+ | `transport` | Transport of the active session (`CarPlayTransport`), or `null`. |
316
+ | `isMonitoring` | Whether persistent monitoring is enabled. |
317
+ | `lastConnectedAt` / `lastDisconnectedAt` | Epoch-ms timestamps of the last transitions, or `null`. |
318
+ | `startMonitoring()` / `stopMonitoring()` | Enable / disable monitoring. |
319
+ | `refresh()` | Re-read the connection + monitoring state from native. |
320
+ | `getDiagnostics()` | Fetch detection diagnostics for troubleshooting. |
321
+
322
+ Pass `autoStart: true` to call `startCarPlayMonitoring()` on mount when it is
323
+ not already enabled.
324
+
325
+ > Both hooks are safe to call on web: the underlying module is a no-op stub
326
+ > there, so the hooks simply report empty / disconnected state.
327
+
328
+ ---
329
+
197
330
  ## Usage Examples
198
331
 
199
332
  ### Scanning for iBeacons
@@ -220,7 +353,8 @@ beacons.forEach((b) => {
220
353
  #### Wildcard scan (Android only)
221
354
 
222
355
  ```ts
223
- // Pass an empty array to discover ALL nearby iBeacons
356
+ // Pass an empty array (or omit the arguments — defaults are uuids = [],
357
+ // scanDuration = 5000) to discover ALL nearby iBeacons.
224
358
  // On iOS, this auto-uses UUIDs from paired beacons
225
359
  const beacons = await ExpoBeacon.scanForBeaconsAsync([], 5000);
226
360
  ```
@@ -567,7 +701,7 @@ await ExpoBeacon.startMonitoring({
567
701
 
568
702
  ### Beacon Timeout
569
703
 
570
- Pair a beacon with `timeoutSeconds` to fire a one-shot event after the beacon has been continuously in range for that duration. The timer resets if the beacon exits and re-enters range.
704
+ Pair a beacon with `timeoutSeconds` to fire a one-shot event after the beacon has been out of range for that duration. The countdown is armed when the beacon exits range (or when no BLE readings arrive for 60 seconds, e.g. due to Doze mode or background throttling) and is cancelled if the beacon is seen again before it fires.
571
705
 
572
706
  ```tsx
573
707
  import { useEffect } from "react";
@@ -581,7 +715,7 @@ ExpoBeacon.pairBeacon(
581
715
  1,
582
716
  100,
583
717
  undefined, // name (optional)
584
- 30, // timeoutSeconds — fires after 30 s in range
718
+ 30, // timeoutSeconds — fires 30 s after the beacon leaves range
585
719
  );
586
720
 
587
721
  // Pair Eddystone with a 60-second timeout
@@ -590,7 +724,7 @@ ExpoBeacon.pairEddystone(
590
724
  "edd1ebeac04e5defa017",
591
725
  "0123456789ab",
592
726
  undefined, // name (optional)
593
- 60, // timeoutSeconds — fires after 60 s in range
727
+ 60, // timeoutSeconds — fires 60 s after the beacon leaves range
594
728
  );
595
729
 
596
730
  // Listen for the timeout events
@@ -598,13 +732,13 @@ useEffect(() => {
598
732
  const beaconTimeout = ExpoBeacon.addListener(
599
733
  "onBeaconTimeout",
600
734
  (e: BeaconTimeoutEvent) => {
601
- console.log(`Beacon "${e.identifier}" in range for configured duration! dist: ${e.distance.toFixed(1)}m`);
735
+ console.log(`Beacon "${e.identifier}" out of range for configured duration!`);
602
736
  },
603
737
  );
604
738
  const eddystoneTimeout = ExpoBeacon.addListener(
605
739
  "onEddystoneTimeout",
606
740
  (e: EddystoneTimeoutEvent) => {
607
- console.log(`Eddystone "${e.identifier}" in range for configured duration!`);
741
+ console.log(`Eddystone "${e.identifier}" out of range for configured duration!`);
608
742
  },
609
743
  );
610
744
 
@@ -615,7 +749,7 @@ useEffect(() => {
615
749
  }, []);
616
750
  ```
617
751
 
618
- > **Note**: The timeout fires once per enter cycle. If the beacon exits and re-enters range, the timer starts over.
752
+ > **Note**: The timeout fires once per exit. If the beacon re-enters range before the countdown completes, the pending timer is cancelled and re-armed on the next exit.
619
753
 
620
754
  ---
621
755
 
@@ -774,13 +908,11 @@ If you need to disable this (e.g. you already ship your own Android Auto templat
774
908
  If `onCarPlayConnected` never fires on Android, call `getCarPlayDiagnostics()` to inspect the native state:
775
909
 
776
910
  ```ts
777
- const d = await ExpoBeacon.getCarPlayDiagnostics();
911
+ const d = ExpoBeacon.getCarPlayDiagnostics();
778
912
  // {
779
- // platform: "android",
780
- // carPlayEnabled: true,
781
913
  // isCarAppMetadataPresent: true, // false → config plugin didn't run; prebuild again
782
914
  // isCarProviderQueryable: true, // false → Android Auto app not installed on device
783
- // lastRawConnectionType: 0, // 0=DISCONNECTED 1=PROJECTION 2=NATIVE
915
+ // lastRawConnectionType: 2, // 0=NOT_CONNECTED 1=NATIVE (AAOS) 2=PROJECTION; null = no value yet
784
916
  // observerActive: true,
785
917
  // serviceAlive: true,
786
918
  // }
@@ -802,8 +934,8 @@ Requests all permissions required for scanning and monitoring.
802
934
 
803
935
  | Platform | Permissions Requested |
804
936
  |---|---|
805
- | **Android** | `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS` (API 33+) |
806
- | **iOS** | `CLLocationManager` "When In Use" "Always" authorization (two-step prompt) |
937
+ | **Android** | `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` (API 31+), `POST_NOTIFICATIONS` (API 33+), then `ACCESS_BACKGROUND_LOCATION` (API 29+) in a second prompt. Resolves `true` only when background location is granted. |
938
+ | **iOS** | `CLLocationManager` "When In Use" authorization — resolves `true` once granted. The "Always" upgrade is requested later by `startMonitoring()`, and Bluetooth permission is not prompted here. |
807
939
 
808
940
  **Returns**: `true` if all required permissions were granted.
809
941
 
@@ -826,6 +958,8 @@ scanForBeaconsAsync(uuids?: string[], scanDurationMs?: number): Promise<BeaconSc
826
958
 
827
959
  Performs a **one-shot iBeacon scan**. Waits for the specified duration, then resolves with all discovered beacons.
828
960
 
961
+ Both parameters are optional — the defaults are applied on the JS side before the native call.
962
+
829
963
  | Parameter | Type | Default | Description |
830
964
  |---|---|---|---|
831
965
  | `uuids` | `string[]` | `[]` | Proximity UUIDs to filter by. See platform differences below. |
@@ -866,6 +1000,8 @@ scanForEddystonesAsync(scanDurationMs?: number): Promise<EddystoneScanResult[]>
866
1000
 
867
1001
  Performs a **one-shot Eddystone scan** using BLE. Discovers both Eddystone-UID and Eddystone-URL frames.
868
1002
 
1003
+ The parameter is optional — the default is applied on the JS side before the native call.
1004
+
869
1005
  | Parameter | Type | Default | Description |
870
1006
  |---|---|---|---|
871
1007
  | `scanDurationMs` | `number` | `5000` | Scan duration in milliseconds (must be > 0). |
@@ -932,19 +1068,19 @@ Registers an iBeacon for persistent monitoring.
932
1068
 
933
1069
  | Parameter | Type | Description |
934
1070
  |---|---|---|
935
- | `identifier` | `string` | Unique label (e.g. `"lobby-entrance"`). Re-using an identifier replaces the previous entry. |
1071
+ | `identifier` | `string` | Unique label (e.g. `"lobby-entrance"`). Re-using an iBeacon identifier replaces the previous entry. |
936
1072
  | `uuid` | `string` | iBeacon proximity UUID (case-insensitive, e.g. `"E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"`) |
937
1073
  | `major` | `number` | Major value: `0`–`65535` |
938
1074
  | `minor` | `number` | Minor value: `0`–`65535` |
939
1075
  | `name` | `string?` | Optional BLE device name for display purposes |
940
- | `timeoutSeconds` | `number?` | Fire `onBeaconTimeout` once after the beacon stays in range this many seconds. Timer resets on exit/re-enter. |
1076
+ | `timeoutSeconds` | `number?` | Fire `onBeaconTimeout` once, this many seconds after the beacon exits range. Cancelled if the beacon is seen again first. |
941
1077
 
942
- **Possible errors**: `INVALID_UUID`, `INVALID_MAJOR`, `INVALID_MINOR`.
1078
+ **Possible errors**: `INVALID_UUID`, `INVALID_MAJOR`, `INVALID_MINOR`, `DUPLICATE_IDENTIFIER` (identifier already used by a paired Eddystone).
943
1079
 
944
1080
  ```ts
945
1081
  ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42);
946
1082
 
947
- // With timeout — fires onBeaconTimeout after 30 s in range
1083
+ // With timeout — fires onBeaconTimeout 30 s after the beacon leaves range
948
1084
  ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42, undefined, 30);
949
1085
  ```
950
1086
 
@@ -989,22 +1125,22 @@ const paired = ExpoBeacon.getPairedBeacons();
989
1125
  pairEddystone(identifier: string, namespace: string, instance: string, name?: string, timeoutSeconds?: number): void
990
1126
  ```
991
1127
 
992
- Registers an Eddystone-UID beacon for persistent monitoring.
1128
+ Registers an Eddystone-UID beacon for persistent monitoring. The namespace and instance are normalized to lowercase before storage.
993
1129
 
994
1130
  | Parameter | Type | Description |
995
1131
  |---|---|---|
996
- | `identifier` | `string` | Unique label (e.g. `"meeting-room"`) |
1132
+ | `identifier` | `string` | Unique label (e.g. `"meeting-room"`). Re-using an Eddystone identifier replaces the previous entry. |
997
1133
  | `namespace` | `string` | 10-byte namespace ID as hex string — must be exactly **20 hex characters** |
998
1134
  | `instance` | `string` | 6-byte instance ID as hex string — must be exactly **12 hex characters** |
999
1135
  | `name` | `string?` | Optional BLE device name for display purposes |
1000
- | `timeoutSeconds` | `number?` | Fire `onEddystoneTimeout` once after the beacon stays in range this many seconds. Timer resets on exit/re-enter. |
1136
+ | `timeoutSeconds` | `number?` | Fire `onEddystoneTimeout` once, this many seconds after the beacon exits range. Cancelled if the beacon is seen again first. |
1001
1137
 
1002
- **Possible errors**: `INVALID_NAMESPACE`, `INVALID_INSTANCE`.
1138
+ **Possible errors**: `INVALID_NAMESPACE`, `INVALID_INSTANCE`, `DUPLICATE_IDENTIFIER` (identifier already used by a paired iBeacon).
1003
1139
 
1004
1140
  ```ts
1005
1141
  ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab");
1006
1142
 
1007
- // With timeout — fires onEddystoneTimeout after 60 s in range
1143
+ // With timeout — fires onEddystoneTimeout 60 s after the beacon leaves range
1008
1144
  ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab", undefined, 60);
1009
1145
  ```
1010
1146
 
@@ -1101,7 +1237,7 @@ await ExpoBeacon.startMonitoring();
1101
1237
  stopMonitoring(): Promise<void>
1102
1238
  ```
1103
1239
 
1104
- Stops all background monitoring. On Android, stops the foreground service.
1240
+ Stops all background monitoring. On Android, stops the foreground service. Persisted monitoring options (`maxDistance`, `exitDistance`, `level`, `exitTimeoutSeconds`, …) are cleared on both platforms.
1105
1241
 
1106
1242
  ```ts
1107
1243
  await ExpoBeacon.stopMonitoring();
@@ -1271,6 +1407,24 @@ ExpoBeacon.destroyEventLogs();
1271
1407
 
1272
1408
  ---
1273
1409
 
1410
+ ### `setApiEndpoint(url, apiKey?, id?)`
1411
+
1412
+ ```ts
1413
+ setApiEndpoint(url: string, apiKey?: string, id?: string): void
1414
+ ```
1415
+
1416
+ Configures a remote endpoint to which native code POSTs every beacon event — delivery works even when the JS bridge is not active (app backgrounded). The configuration persists until changed.
1417
+
1418
+ | Parameter | Type | Description |
1419
+ |---|---|---|
1420
+ | `url` | `string` | The API endpoint URL to POST events to. |
1421
+ | `apiKey` | `string?` | Sent as the `X-CSFR-Token` header (sic — the header is literally `X-CSFR-Token`, not `X-CSRF-Token`). |
1422
+ | `id` | `string?` | Identifier appended to every forwarded event payload. |
1423
+
1424
+ Use `getApiEndpoint()` to read back the current configuration (each field is `null` if unset).
1425
+
1426
+ ---
1427
+
1274
1428
  ## Events
1275
1429
 
1276
1430
  Subscribe with `ExpoBeacon.addListener(eventName, handler)`. Always call `.remove()` on the returned subscription during cleanup.
@@ -1293,8 +1447,8 @@ sub.remove();
1293
1447
  | `onEddystoneEnter` | Paired Eddystone enters range (respects `maxDistance`) | `EddystoneRegionEvent` |
1294
1448
  | `onEddystoneExit` | Paired Eddystone leaves range (always fires) | `EddystoneRegionEvent` |
1295
1449
  | `onEddystoneDistance` | Periodic Eddystone distance update during monitoring | `EddystoneDistanceEvent` |
1296
- | `onBeaconTimeout` | Paired iBeacon in range for configured `timeoutSeconds` | `BeaconTimeoutEvent` |
1297
- | `onEddystoneTimeout` | Paired Eddystone in range for configured `timeoutSeconds` | `EddystoneTimeoutEvent` |
1450
+ | `onBeaconTimeout` | Paired iBeacon out of range for configured `timeoutSeconds` | `BeaconTimeoutEvent` |
1451
+ | `onEddystoneTimeout` | Paired Eddystone out of range for configured `timeoutSeconds` | `EddystoneTimeoutEvent` |
1298
1452
 
1299
1453
  ### Event Detail
1300
1454
 
@@ -1391,25 +1545,25 @@ ExpoBeacon.addListener("onEddystoneDistance", (e) => {
1391
1545
 
1392
1546
  #### `onBeaconTimeout`
1393
1547
 
1394
- Fired **once** when a paired iBeacon has been continuously in range for its configured `timeoutSeconds` duration. The timer resets on exit/re-enter.
1548
+ Fired **once**, `timeoutSeconds` after a paired iBeacon exits range (or after BLE readings stop for 60 s). Re-detection cancels the pending timer.
1395
1549
 
1396
1550
  ```ts
1397
1551
  ExpoBeacon.addListener("onBeaconTimeout", (e) => {
1398
1552
  // e.identifier — "lobby-entrance"
1399
1553
  // e.uuid, e.major, e.minor — beacon identity
1400
- // e.distance — metres at the moment the timeout fired
1401
- console.log(`Beacon "${e.identifier}" timeout — in range for configured duration`);
1554
+ // e.distance — usually –1 (the beacon is out of range when this fires)
1555
+ console.log(`Beacon "${e.identifier}" timeout — out of range for configured duration`);
1402
1556
  });
1403
1557
  ```
1404
1558
 
1405
1559
  #### `onEddystoneTimeout`
1406
1560
 
1407
- Fired **once** when a paired Eddystone has been continuously in range for its configured `timeoutSeconds` duration.
1561
+ Fired **once**, `timeoutSeconds` after a paired Eddystone exits range (or after BLE readings stop for 60 s). Re-detection cancels the pending timer.
1408
1562
 
1409
1563
  ```ts
1410
1564
  ExpoBeacon.addListener("onEddystoneTimeout", (e) => {
1411
1565
  // e.identifier, e.namespace, e.instance — Eddystone identity
1412
- // e.distance — metres at the moment the timeout fired (–1 if unavailable)
1566
+ // e.distance — usually –1 (the beacon is out of range when this fires)
1413
1567
  console.log(`Eddystone "${e.identifier}" timeout`);
1414
1568
  });
1415
1569
  ```
@@ -1433,6 +1587,7 @@ import type {
1433
1587
  EddystoneRegionEvent,
1434
1588
  EddystoneDistanceEvent,
1435
1589
  EddystoneTimeoutEvent,
1590
+ BeaconErrorEvent,
1436
1591
  ExpoBeaconModuleEvents,
1437
1592
  MonitoringOptions,
1438
1593
  NotificationConfig,
@@ -1454,8 +1609,8 @@ type BeaconScanResult = {
1454
1609
  major: number; // 0–65535
1455
1610
  minor: number; // 0–65535
1456
1611
  rssi: number; // Signal strength in dBm (negative, e.g. –65)
1457
- distance: number; // Estimated distance in metres
1458
- txPower: number; // Calibrated TX power from the advertisement
1612
+ distance: number; // Estimated distance in metres (–1 when unavailable)
1613
+ txPower: number; // Calibrated TX power. Android only — always 0 on iOS (CoreLocation does not expose it)
1459
1614
  };
1460
1615
  ```
1461
1616
 
@@ -1470,7 +1625,7 @@ type PairedBeacon = {
1470
1625
  major: number;
1471
1626
  minor: number;
1472
1627
  name?: string; // Optional BLE device name
1473
- timeoutSeconds?: number; // Fires onBeaconTimeout after this duration in range
1628
+ timeoutSeconds?: number; // Fires onBeaconTimeout this many seconds after the beacon exits range
1474
1629
  };
1475
1630
  ```
1476
1631
 
@@ -1529,7 +1684,7 @@ type PairedEddystone = {
1529
1684
  namespace: string; // 20 hex chars
1530
1685
  instance: string; // 12 hex chars
1531
1686
  name?: string; // Optional BLE device name
1532
- timeoutSeconds?: number; // Fires onEddystoneTimeout after this duration in range
1687
+ timeoutSeconds?: number; // Fires onEddystoneTimeout this many seconds after the beacon exits range
1533
1688
  };
1534
1689
  ```
1535
1690
 
@@ -1670,7 +1825,7 @@ type BeaconTimeoutEvent = {
1670
1825
  uuid: string;
1671
1826
  major: number;
1672
1827
  minor: number;
1673
- distance: number; // Metres at timeout fire (–1 if unavailable)
1828
+ distance: number; // Usually –1 (the beacon is out of range when the timeout fires)
1674
1829
  };
1675
1830
  ```
1676
1831
 
@@ -1683,7 +1838,7 @@ type EddystoneTimeoutEvent = {
1683
1838
  identifier: string;
1684
1839
  namespace: string;
1685
1840
  instance: string;
1686
- distance: number; // Metres at timeout fire (–1 if unavailable)
1841
+ distance: number; // Usually –1 (the beacon is out of range when the timeout fires)
1687
1842
  };
1688
1843
  ```
1689
1844
 
@@ -1737,13 +1892,13 @@ Follow [react-native-background-geolocation's native setup](https://transistorso
1737
1892
 
1738
1893
  #### 2. Add the Expo config plugin
1739
1894
 
1740
- In `app.json` (or `app.config.js`), add `expo-beacon/plugin/withBeaconBGLocation` to your plugins list:
1895
+ In `app.json` (or `app.config.js`), add `expo-beacon` to your plugins list:
1741
1896
 
1742
1897
  ```json
1743
1898
  {
1744
1899
  "expo": {
1745
1900
  "plugins": [
1746
- "expo-beacon/plugin/withBeaconBGLocation"
1901
+ "expo-beacon"
1747
1902
  ]
1748
1903
  }
1749
1904
  }
@@ -1755,7 +1910,28 @@ Then run prebuild to apply the native changes:
1755
1910
  npx expo prebuild --clean
1756
1911
  ```
1757
1912
 
1758
- The plugin writes `BeaconGeoPlugin.swift` / `BeaconGeoPlugin.kt` into your native project and wires them up in `AppDelegate.swift` and `MainApplication.kt` automatically.
1913
+ The plugin writes `BeaconGeoPlugin.swift` / `BeaconGeoPlugin.kt` into your native project and wires them up in `AppDelegate.swift` and `MainApplication.kt` automatically. It also merges `location` into the iOS `UIBackgroundModes` (required for background ranging) and registers the app as Android-Auto-aware (see [CarPlay / Android Auto Detection](#carplay--android-auto-detection)).
1914
+
1915
+ > **Java projects**: the `MainApplication` patch is Kotlin-only. If your project still uses `MainApplication.java`, the plugin skips the patch and you must add `BeaconPluginRegistry.register(BeaconGeoPlugin(this))` manually.
1916
+
1917
+ ##### The `backgroundGeolocation` prop
1918
+
1919
+ The `BeaconGeoPlugin` generation requires `react-native-background-geolocation` to be installed — without it, the generated native files fail to compile. If you don't use that library, disable the integration with the `backgroundGeolocation` prop (default: `true`):
1920
+
1921
+ ```json
1922
+ {
1923
+ "expo": {
1924
+ "plugins": [
1925
+ ["expo-beacon", {
1926
+ "ios": { "backgroundGeolocation": false },
1927
+ "android": { "backgroundGeolocation": false }
1928
+ }]
1929
+ ]
1930
+ }
1931
+ }
1932
+ ```
1933
+
1934
+ Setting it to `false` skips the `BeaconGeoPlugin.swift` / `BeaconGeoPlugin.kt` generation and the `AppDelegate.swift` / `MainApplication.kt` patches. The `UIBackgroundModes` merge and the Android Auto registration are applied regardless of this prop.
1759
1935
 
1760
1936
  #### 3. Configure BGLocation once at JS startup
1761
1937
 
@@ -1796,18 +1972,42 @@ import ExpoBeacon
1796
1972
  import TSLocationManager
1797
1973
 
1798
1974
  final class BeaconGeoPlugin: BeaconLifecycleDelegate {
1799
- func beaconDidEnter(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1800
- TSLocationManager.sharedManager().start()
1801
- }
1802
- func beaconDidExit(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1803
- TSLocationManager.sharedManager().stop()
1804
- }
1805
- func eddystoneDidEnter(identifier: String, namespace: String, instance: String, distance: Double) {
1806
- TSLocationManager.sharedManager().start()
1807
- }
1808
- func eddystoneDidExit(identifier: String, namespace: String, instance: String, distance: Double) {
1809
- TSLocationManager.sharedManager().stop()
1810
- }
1975
+ private func startTracking() {
1976
+ BackgroundGeolocation.sharedInstance().start()
1977
+ BackgroundGeolocation.sharedInstance().changePace(true)
1978
+ }
1979
+
1980
+ private func stopTracking() {
1981
+ BackgroundGeolocation.sharedInstance().changePace(false)
1982
+ BackgroundGeolocation.sharedInstance().stop()
1983
+ }
1984
+
1985
+ func beaconDidEnter(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1986
+ startTracking()
1987
+ }
1988
+ func beaconDidExit(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1989
+ stopTracking()
1990
+ }
1991
+ func beaconDidTimeout(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1992
+ stopTracking()
1993
+ }
1994
+ func eddystoneDidEnter(identifier: String, namespace: String, instance: String, distance: Double) {
1995
+ startTracking()
1996
+ }
1997
+ func eddystoneDidExit(identifier: String, namespace: String, instance: String, distance: Double) {
1998
+ stopTracking()
1999
+ }
2000
+ func eddystoneDidTimeout(identifier: String, namespace: String, instance: String, distance: Double) {
2001
+ stopTracking()
2002
+ }
2003
+ // Start tracking when the device connects to CarPlay (wired or wireless),
2004
+ // stop when it disconnects.
2005
+ func carPlayDidConnect(transport: String) {
2006
+ startTracking()
2007
+ }
2008
+ func carPlayDidDisconnect() {
2009
+ stopTracking()
2010
+ }
1811
2011
  }
1812
2012
  ```
1813
2013
 
@@ -1828,19 +2028,35 @@ package com.yourapp
1828
2028
 
1829
2029
  import android.content.Context
1830
2030
  import com.transistorsoft.locationmanager.adapter.BackgroundGeolocation
2031
+ import com.transistorsoft.locationmanager.adapter.callback.TSCallback
1831
2032
  import expo.modules.beacon.BeaconEventPlugin
1832
2033
 
1833
2034
  class BeaconGeoPlugin(ctx: Context) : BeaconEventPlugin {
1834
2035
  private val bgGeo = BackgroundGeolocation.getInstance(ctx, null)
2036
+ private val noOp = object : TSCallback {
2037
+ override fun onSuccess() {}
2038
+ override fun onFailure(error: String) {}
2039
+ }
1835
2040
 
1836
2041
  override fun onBeaconEnter(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) =
1837
- bgGeo.start(null)
2042
+ bgGeo.start(noOp)
1838
2043
  override fun onBeaconExit(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) =
1839
- bgGeo.stop(null)
2044
+ bgGeo.stop(noOp)
2045
+ override fun onBeaconTimeout(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) =
2046
+ bgGeo.stop(noOp)
1840
2047
  override fun onEddystoneEnter(identifier: String, namespace: String, instance: String, distance: Double) =
1841
- bgGeo.start(null)
2048
+ bgGeo.start(noOp)
1842
2049
  override fun onEddystoneExit(identifier: String, namespace: String, instance: String, distance: Double) =
1843
- bgGeo.stop(null)
2050
+ bgGeo.stop(noOp)
2051
+ override fun onEddystoneTimeout(identifier: String, namespace: String, instance: String, distance: Double) =
2052
+ bgGeo.stop(noOp)
2053
+ // Start tracking when the device connects to Android Auto, stop when it disconnects.
2054
+ override fun onCarPlayConnected(transport: String) {
2055
+ bgGeo.start(noOp)
2056
+ }
2057
+ override fun onCarPlayDisconnected() {
2058
+ bgGeo.stop(noOp)
2059
+ }
1844
2060
  }
1845
2061
  ```
1846
2062
 
@@ -1878,7 +2094,7 @@ override fun onCreate() {
1878
2094
  |---|---|
1879
2095
  | Region monitoring | iOS wakes/relaunches the app on region boundary crossings — even if force-quit. |
1880
2096
  | BLE scanning | Eddystones are monitored via CoreBluetooth. Works reliably in foreground; may be throttled when the app is suspended. |
1881
- | Background modes | `allowsBackgroundLocationUpdates = true`, `pausesLocationUpdatesAutomatically = false` |
2097
+ | Background modes | `allowsBackgroundLocationUpdates` is only enabled when `UIBackgroundModes` contains `location` (the config plugin adds it on prebuild); `pausesLocationUpdatesAutomatically = false` |
1882
2098
  | Region limit | 20 simultaneous `CLBeaconRegion` registrations max. Eddystones don't count. |
1883
2099
 
1884
2100
  ---
@@ -1915,7 +2131,7 @@ Both the foreground service and enter/exit alerts share the channel ID `expo_bea
1915
2131
 
1916
2132
  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.
1917
2133
 
1918
- 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()`.
2134
+ 2. **Two-step location permission**: iOS requires requesting "When In Use" first, then upgrading to "Always". `requestPermissionsAsync()` requests (and resolves `true` with) "When In Use"; the "Always" upgrade prompt is triggered by `startMonitoring()`.
1919
2135
 
1920
2136
  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.
1921
2137
 
@@ -1995,6 +2211,7 @@ The module uses hysteresis (3 consecutive readings) to prevent jitter. If you're
1995
2211
  | `INVALID_MINOR` | `pairBeacon` | Minor value not in range 0–65535. |
1996
2212
  | `INVALID_NAMESPACE` | `pairEddystone` | Namespace must be exactly 20 hex characters. |
1997
2213
  | `INVALID_INSTANCE` | `pairEddystone` | Instance must be exactly 12 hex characters. |
2214
+ | `DUPLICATE_IDENTIFIER` | `pairBeacon`, `pairEddystone` | The identifier is already used by a paired beacon of the other type. |
1998
2215
  | `PERMISSION_DENIED` | `scanForBeaconsAsync`, `startMonitoring` | Required permissions were not granted. |
1999
2216
  | `WILDCARD_NOT_SUPPORTED` | `scanForBeaconsAsync` | iOS only: no UUIDs provided and no paired beacons exist. |
2000
2217
 
@@ -11,8 +11,6 @@
11
11
  <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
12
12
  <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
13
13
  <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
14
- <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
15
- <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
16
14
 
17
15
  <!-- Location permissions required for BLE scanning -->
18
16
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />