expo-beacon 0.9.3 → 0.10.0

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 +268 -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 +100 -0
  23. package/build/hooks/useBeacon.d.ts.map +1 -0
  24. package/build/hooks/useBeacon.js +225 -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 +399 -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,125 @@ 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
+ | `refreshPaired()` | Re-read the paired lists from native. |
264
+ | `pairBeacon()` / `unpairBeacon()` | Pair / unpair an iBeacon, then refresh. |
265
+ | `pairEddystone()` / `unpairEddystone()` | Pair / unpair an Eddystone, then refresh. |
266
+ | `scanForBeacons()` / `scanForEddystones()` | One-shot scans returning a promise. |
267
+ | `startContinuousScan()` / `stopContinuousScan()` | Live scan; results arrive via `onBeaconFound` / `onEddystoneFound`. |
268
+ | `cancelScan()` | Cancel an in-progress one-shot scan. |
269
+ | `startMonitoring()` / `stopMonitoring()` | Start / stop background monitoring. |
270
+ | `requestPermissions()` | Request the permissions needed for scanning / monitoring. |
271
+
272
+ `inRange` reflects **monitored (paired)** beacons only. Continuous-scan results
273
+ are delivered through the `onBeaconFound` / `onEddystoneFound` callbacks because
274
+ raw scan hits carry no paired identifier. Pass `track: false` to skip `inRange`
275
+ bookkeeping when you only need the callbacks.
276
+
277
+ ### useCarPlay()
278
+
279
+ Observes CarPlay / Android Auto connection state. It initializes from the
280
+ persisted native state on mount and tracks live connect / disconnect events.
281
+
282
+ ```tsx
283
+ import { useCarPlay } from "expo-beacon";
284
+
285
+ function CarPlayBadge() {
286
+ const { connected, transport, isMonitoring, startMonitoring, stopMonitoring } =
287
+ useCarPlay({
288
+ onConnected: (e) => console.log("car connected via", e.transport),
289
+ onDisconnected: () => console.log("car disconnected"),
290
+ });
291
+
292
+ return (
293
+ <View>
294
+ <Text>{connected ? `Connected (${transport})` : "Not connected"}</Text>
295
+ <Button
296
+ title={isMonitoring ? "Stop" : "Start"}
297
+ onPress={() => (isMonitoring ? stopMonitoring() : startMonitoring())}
298
+ />
299
+ </View>
300
+ );
301
+ }
302
+ ```
303
+
304
+ | Returned value | Description |
305
+ | --- | --- |
306
+ | `connected` | Whether a CarPlay / Android Auto session is active. |
307
+ | `transport` | Transport of the active session (`CarPlayTransport`), or `null`. |
308
+ | `isMonitoring` | Whether persistent monitoring is enabled. |
309
+ | `lastConnectedAt` / `lastDisconnectedAt` | Epoch-ms timestamps of the last transitions, or `null`. |
310
+ | `startMonitoring()` / `stopMonitoring()` | Enable / disable monitoring. |
311
+ | `refresh()` | Re-read the connection + monitoring state from native. |
312
+ | `getDiagnostics()` | Fetch detection diagnostics for troubleshooting. |
313
+
314
+ Pass `autoStart: true` to call `startCarPlayMonitoring()` on mount when it is
315
+ not already enabled.
316
+
317
+ > Both hooks are safe to call on web: the underlying module is a no-op stub
318
+ > there, so the hooks simply report empty / disconnected state.
319
+
320
+ ---
321
+
197
322
  ## Usage Examples
198
323
 
199
324
  ### Scanning for iBeacons
@@ -220,7 +345,8 @@ beacons.forEach((b) => {
220
345
  #### Wildcard scan (Android only)
221
346
 
222
347
  ```ts
223
- // Pass an empty array to discover ALL nearby iBeacons
348
+ // Pass an empty array (or omit the arguments — defaults are uuids = [],
349
+ // scanDuration = 5000) to discover ALL nearby iBeacons.
224
350
  // On iOS, this auto-uses UUIDs from paired beacons
225
351
  const beacons = await ExpoBeacon.scanForBeaconsAsync([], 5000);
226
352
  ```
@@ -567,7 +693,7 @@ await ExpoBeacon.startMonitoring({
567
693
 
568
694
  ### Beacon Timeout
569
695
 
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.
696
+ 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
697
 
572
698
  ```tsx
573
699
  import { useEffect } from "react";
@@ -581,7 +707,7 @@ ExpoBeacon.pairBeacon(
581
707
  1,
582
708
  100,
583
709
  undefined, // name (optional)
584
- 30, // timeoutSeconds — fires after 30 s in range
710
+ 30, // timeoutSeconds — fires 30 s after the beacon leaves range
585
711
  );
586
712
 
587
713
  // Pair Eddystone with a 60-second timeout
@@ -590,7 +716,7 @@ ExpoBeacon.pairEddystone(
590
716
  "edd1ebeac04e5defa017",
591
717
  "0123456789ab",
592
718
  undefined, // name (optional)
593
- 60, // timeoutSeconds — fires after 60 s in range
719
+ 60, // timeoutSeconds — fires 60 s after the beacon leaves range
594
720
  );
595
721
 
596
722
  // Listen for the timeout events
@@ -598,13 +724,13 @@ useEffect(() => {
598
724
  const beaconTimeout = ExpoBeacon.addListener(
599
725
  "onBeaconTimeout",
600
726
  (e: BeaconTimeoutEvent) => {
601
- console.log(`Beacon "${e.identifier}" in range for configured duration! dist: ${e.distance.toFixed(1)}m`);
727
+ console.log(`Beacon "${e.identifier}" out of range for configured duration!`);
602
728
  },
603
729
  );
604
730
  const eddystoneTimeout = ExpoBeacon.addListener(
605
731
  "onEddystoneTimeout",
606
732
  (e: EddystoneTimeoutEvent) => {
607
- console.log(`Eddystone "${e.identifier}" in range for configured duration!`);
733
+ console.log(`Eddystone "${e.identifier}" out of range for configured duration!`);
608
734
  },
609
735
  );
610
736
 
@@ -615,7 +741,7 @@ useEffect(() => {
615
741
  }, []);
616
742
  ```
617
743
 
618
- > **Note**: The timeout fires once per enter cycle. If the beacon exits and re-enters range, the timer starts over.
744
+ > **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
745
 
620
746
  ---
621
747
 
@@ -774,13 +900,11 @@ If you need to disable this (e.g. you already ship your own Android Auto templat
774
900
  If `onCarPlayConnected` never fires on Android, call `getCarPlayDiagnostics()` to inspect the native state:
775
901
 
776
902
  ```ts
777
- const d = await ExpoBeacon.getCarPlayDiagnostics();
903
+ const d = ExpoBeacon.getCarPlayDiagnostics();
778
904
  // {
779
- // platform: "android",
780
- // carPlayEnabled: true,
781
905
  // isCarAppMetadataPresent: true, // false → config plugin didn't run; prebuild again
782
906
  // isCarProviderQueryable: true, // false → Android Auto app not installed on device
783
- // lastRawConnectionType: 0, // 0=DISCONNECTED 1=PROJECTION 2=NATIVE
907
+ // lastRawConnectionType: 2, // 0=NOT_CONNECTED 1=NATIVE (AAOS) 2=PROJECTION; null = no value yet
784
908
  // observerActive: true,
785
909
  // serviceAlive: true,
786
910
  // }
@@ -802,8 +926,8 @@ Requests all permissions required for scanning and monitoring.
802
926
 
803
927
  | Platform | Permissions Requested |
804
928
  |---|---|
805
- | **Android** | `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS` (API 33+) |
806
- | **iOS** | `CLLocationManager` "When In Use" "Always" authorization (two-step prompt) |
929
+ | **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. |
930
+ | **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
931
 
808
932
  **Returns**: `true` if all required permissions were granted.
809
933
 
@@ -826,6 +950,8 @@ scanForBeaconsAsync(uuids?: string[], scanDurationMs?: number): Promise<BeaconSc
826
950
 
827
951
  Performs a **one-shot iBeacon scan**. Waits for the specified duration, then resolves with all discovered beacons.
828
952
 
953
+ Both parameters are optional — the defaults are applied on the JS side before the native call.
954
+
829
955
  | Parameter | Type | Default | Description |
830
956
  |---|---|---|---|
831
957
  | `uuids` | `string[]` | `[]` | Proximity UUIDs to filter by. See platform differences below. |
@@ -866,6 +992,8 @@ scanForEddystonesAsync(scanDurationMs?: number): Promise<EddystoneScanResult[]>
866
992
 
867
993
  Performs a **one-shot Eddystone scan** using BLE. Discovers both Eddystone-UID and Eddystone-URL frames.
868
994
 
995
+ The parameter is optional — the default is applied on the JS side before the native call.
996
+
869
997
  | Parameter | Type | Default | Description |
870
998
  |---|---|---|---|
871
999
  | `scanDurationMs` | `number` | `5000` | Scan duration in milliseconds (must be > 0). |
@@ -932,19 +1060,19 @@ Registers an iBeacon for persistent monitoring.
932
1060
 
933
1061
  | Parameter | Type | Description |
934
1062
  |---|---|---|
935
- | `identifier` | `string` | Unique label (e.g. `"lobby-entrance"`). Re-using an identifier replaces the previous entry. |
1063
+ | `identifier` | `string` | Unique label (e.g. `"lobby-entrance"`). Re-using an iBeacon identifier replaces the previous entry. |
936
1064
  | `uuid` | `string` | iBeacon proximity UUID (case-insensitive, e.g. `"E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"`) |
937
1065
  | `major` | `number` | Major value: `0`–`65535` |
938
1066
  | `minor` | `number` | Minor value: `0`–`65535` |
939
1067
  | `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. |
1068
+ | `timeoutSeconds` | `number?` | Fire `onBeaconTimeout` once, this many seconds after the beacon exits range. Cancelled if the beacon is seen again first. |
941
1069
 
942
- **Possible errors**: `INVALID_UUID`, `INVALID_MAJOR`, `INVALID_MINOR`.
1070
+ **Possible errors**: `INVALID_UUID`, `INVALID_MAJOR`, `INVALID_MINOR`, `DUPLICATE_IDENTIFIER` (identifier already used by a paired Eddystone).
943
1071
 
944
1072
  ```ts
945
1073
  ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42);
946
1074
 
947
- // With timeout — fires onBeaconTimeout after 30 s in range
1075
+ // With timeout — fires onBeaconTimeout 30 s after the beacon leaves range
948
1076
  ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42, undefined, 30);
949
1077
  ```
950
1078
 
@@ -989,22 +1117,22 @@ const paired = ExpoBeacon.getPairedBeacons();
989
1117
  pairEddystone(identifier: string, namespace: string, instance: string, name?: string, timeoutSeconds?: number): void
990
1118
  ```
991
1119
 
992
- Registers an Eddystone-UID beacon for persistent monitoring.
1120
+ Registers an Eddystone-UID beacon for persistent monitoring. The namespace and instance are normalized to lowercase before storage.
993
1121
 
994
1122
  | Parameter | Type | Description |
995
1123
  |---|---|---|
996
- | `identifier` | `string` | Unique label (e.g. `"meeting-room"`) |
1124
+ | `identifier` | `string` | Unique label (e.g. `"meeting-room"`). Re-using an Eddystone identifier replaces the previous entry. |
997
1125
  | `namespace` | `string` | 10-byte namespace ID as hex string — must be exactly **20 hex characters** |
998
1126
  | `instance` | `string` | 6-byte instance ID as hex string — must be exactly **12 hex characters** |
999
1127
  | `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. |
1128
+ | `timeoutSeconds` | `number?` | Fire `onEddystoneTimeout` once, this many seconds after the beacon exits range. Cancelled if the beacon is seen again first. |
1001
1129
 
1002
- **Possible errors**: `INVALID_NAMESPACE`, `INVALID_INSTANCE`.
1130
+ **Possible errors**: `INVALID_NAMESPACE`, `INVALID_INSTANCE`, `DUPLICATE_IDENTIFIER` (identifier already used by a paired iBeacon).
1003
1131
 
1004
1132
  ```ts
1005
1133
  ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab");
1006
1134
 
1007
- // With timeout — fires onEddystoneTimeout after 60 s in range
1135
+ // With timeout — fires onEddystoneTimeout 60 s after the beacon leaves range
1008
1136
  ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab", undefined, 60);
1009
1137
  ```
1010
1138
 
@@ -1101,7 +1229,7 @@ await ExpoBeacon.startMonitoring();
1101
1229
  stopMonitoring(): Promise<void>
1102
1230
  ```
1103
1231
 
1104
- Stops all background monitoring. On Android, stops the foreground service.
1232
+ Stops all background monitoring. On Android, stops the foreground service. Persisted monitoring options (`maxDistance`, `exitDistance`, `level`, `exitTimeoutSeconds`, …) are cleared on both platforms.
1105
1233
 
1106
1234
  ```ts
1107
1235
  await ExpoBeacon.stopMonitoring();
@@ -1271,6 +1399,24 @@ ExpoBeacon.destroyEventLogs();
1271
1399
 
1272
1400
  ---
1273
1401
 
1402
+ ### `setApiEndpoint(url, apiKey?, id?)`
1403
+
1404
+ ```ts
1405
+ setApiEndpoint(url: string, apiKey?: string, id?: string): void
1406
+ ```
1407
+
1408
+ 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.
1409
+
1410
+ | Parameter | Type | Description |
1411
+ |---|---|---|
1412
+ | `url` | `string` | The API endpoint URL to POST events to. |
1413
+ | `apiKey` | `string?` | Sent as the `X-CSFR-Token` header (sic — the header is literally `X-CSFR-Token`, not `X-CSRF-Token`). |
1414
+ | `id` | `string?` | Identifier appended to every forwarded event payload. |
1415
+
1416
+ Use `getApiEndpoint()` to read back the current configuration (each field is `null` if unset).
1417
+
1418
+ ---
1419
+
1274
1420
  ## Events
1275
1421
 
1276
1422
  Subscribe with `ExpoBeacon.addListener(eventName, handler)`. Always call `.remove()` on the returned subscription during cleanup.
@@ -1293,8 +1439,8 @@ sub.remove();
1293
1439
  | `onEddystoneEnter` | Paired Eddystone enters range (respects `maxDistance`) | `EddystoneRegionEvent` |
1294
1440
  | `onEddystoneExit` | Paired Eddystone leaves range (always fires) | `EddystoneRegionEvent` |
1295
1441
  | `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` |
1442
+ | `onBeaconTimeout` | Paired iBeacon out of range for configured `timeoutSeconds` | `BeaconTimeoutEvent` |
1443
+ | `onEddystoneTimeout` | Paired Eddystone out of range for configured `timeoutSeconds` | `EddystoneTimeoutEvent` |
1298
1444
 
1299
1445
  ### Event Detail
1300
1446
 
@@ -1391,25 +1537,25 @@ ExpoBeacon.addListener("onEddystoneDistance", (e) => {
1391
1537
 
1392
1538
  #### `onBeaconTimeout`
1393
1539
 
1394
- Fired **once** when a paired iBeacon has been continuously in range for its configured `timeoutSeconds` duration. The timer resets on exit/re-enter.
1540
+ Fired **once**, `timeoutSeconds` after a paired iBeacon exits range (or after BLE readings stop for 60 s). Re-detection cancels the pending timer.
1395
1541
 
1396
1542
  ```ts
1397
1543
  ExpoBeacon.addListener("onBeaconTimeout", (e) => {
1398
1544
  // e.identifier — "lobby-entrance"
1399
1545
  // 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`);
1546
+ // e.distance — usually –1 (the beacon is out of range when this fires)
1547
+ console.log(`Beacon "${e.identifier}" timeout — out of range for configured duration`);
1402
1548
  });
1403
1549
  ```
1404
1550
 
1405
1551
  #### `onEddystoneTimeout`
1406
1552
 
1407
- Fired **once** when a paired Eddystone has been continuously in range for its configured `timeoutSeconds` duration.
1553
+ Fired **once**, `timeoutSeconds` after a paired Eddystone exits range (or after BLE readings stop for 60 s). Re-detection cancels the pending timer.
1408
1554
 
1409
1555
  ```ts
1410
1556
  ExpoBeacon.addListener("onEddystoneTimeout", (e) => {
1411
1557
  // e.identifier, e.namespace, e.instance — Eddystone identity
1412
- // e.distance — metres at the moment the timeout fired (–1 if unavailable)
1558
+ // e.distance — usually –1 (the beacon is out of range when this fires)
1413
1559
  console.log(`Eddystone "${e.identifier}" timeout`);
1414
1560
  });
1415
1561
  ```
@@ -1433,6 +1579,7 @@ import type {
1433
1579
  EddystoneRegionEvent,
1434
1580
  EddystoneDistanceEvent,
1435
1581
  EddystoneTimeoutEvent,
1582
+ BeaconErrorEvent,
1436
1583
  ExpoBeaconModuleEvents,
1437
1584
  MonitoringOptions,
1438
1585
  NotificationConfig,
@@ -1454,8 +1601,8 @@ type BeaconScanResult = {
1454
1601
  major: number; // 0–65535
1455
1602
  minor: number; // 0–65535
1456
1603
  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
1604
+ distance: number; // Estimated distance in metres (–1 when unavailable)
1605
+ txPower: number; // Calibrated TX power. Android only — always 0 on iOS (CoreLocation does not expose it)
1459
1606
  };
1460
1607
  ```
1461
1608
 
@@ -1470,7 +1617,7 @@ type PairedBeacon = {
1470
1617
  major: number;
1471
1618
  minor: number;
1472
1619
  name?: string; // Optional BLE device name
1473
- timeoutSeconds?: number; // Fires onBeaconTimeout after this duration in range
1620
+ timeoutSeconds?: number; // Fires onBeaconTimeout this many seconds after the beacon exits range
1474
1621
  };
1475
1622
  ```
1476
1623
 
@@ -1529,7 +1676,7 @@ type PairedEddystone = {
1529
1676
  namespace: string; // 20 hex chars
1530
1677
  instance: string; // 12 hex chars
1531
1678
  name?: string; // Optional BLE device name
1532
- timeoutSeconds?: number; // Fires onEddystoneTimeout after this duration in range
1679
+ timeoutSeconds?: number; // Fires onEddystoneTimeout this many seconds after the beacon exits range
1533
1680
  };
1534
1681
  ```
1535
1682
 
@@ -1670,7 +1817,7 @@ type BeaconTimeoutEvent = {
1670
1817
  uuid: string;
1671
1818
  major: number;
1672
1819
  minor: number;
1673
- distance: number; // Metres at timeout fire (–1 if unavailable)
1820
+ distance: number; // Usually –1 (the beacon is out of range when the timeout fires)
1674
1821
  };
1675
1822
  ```
1676
1823
 
@@ -1683,7 +1830,7 @@ type EddystoneTimeoutEvent = {
1683
1830
  identifier: string;
1684
1831
  namespace: string;
1685
1832
  instance: string;
1686
- distance: number; // Metres at timeout fire (–1 if unavailable)
1833
+ distance: number; // Usually –1 (the beacon is out of range when the timeout fires)
1687
1834
  };
1688
1835
  ```
1689
1836
 
@@ -1737,13 +1884,13 @@ Follow [react-native-background-geolocation's native setup](https://transistorso
1737
1884
 
1738
1885
  #### 2. Add the Expo config plugin
1739
1886
 
1740
- In `app.json` (or `app.config.js`), add `expo-beacon/plugin/withBeaconBGLocation` to your plugins list:
1887
+ In `app.json` (or `app.config.js`), add `expo-beacon` to your plugins list:
1741
1888
 
1742
1889
  ```json
1743
1890
  {
1744
1891
  "expo": {
1745
1892
  "plugins": [
1746
- "expo-beacon/plugin/withBeaconBGLocation"
1893
+ "expo-beacon"
1747
1894
  ]
1748
1895
  }
1749
1896
  }
@@ -1755,7 +1902,28 @@ Then run prebuild to apply the native changes:
1755
1902
  npx expo prebuild --clean
1756
1903
  ```
1757
1904
 
1758
- The plugin writes `BeaconGeoPlugin.swift` / `BeaconGeoPlugin.kt` into your native project and wires them up in `AppDelegate.swift` and `MainApplication.kt` automatically.
1905
+ 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)).
1906
+
1907
+ > **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.
1908
+
1909
+ ##### The `backgroundGeolocation` prop
1910
+
1911
+ 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`):
1912
+
1913
+ ```json
1914
+ {
1915
+ "expo": {
1916
+ "plugins": [
1917
+ ["expo-beacon", {
1918
+ "ios": { "backgroundGeolocation": false },
1919
+ "android": { "backgroundGeolocation": false }
1920
+ }]
1921
+ ]
1922
+ }
1923
+ }
1924
+ ```
1925
+
1926
+ 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
1927
 
1760
1928
  #### 3. Configure BGLocation once at JS startup
1761
1929
 
@@ -1796,18 +1964,42 @@ import ExpoBeacon
1796
1964
  import TSLocationManager
1797
1965
 
1798
1966
  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
- }
1967
+ private func startTracking() {
1968
+ BackgroundGeolocation.sharedInstance().start()
1969
+ BackgroundGeolocation.sharedInstance().changePace(true)
1970
+ }
1971
+
1972
+ private func stopTracking() {
1973
+ BackgroundGeolocation.sharedInstance().changePace(false)
1974
+ BackgroundGeolocation.sharedInstance().stop()
1975
+ }
1976
+
1977
+ func beaconDidEnter(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1978
+ startTracking()
1979
+ }
1980
+ func beaconDidExit(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1981
+ stopTracking()
1982
+ }
1983
+ func beaconDidTimeout(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) {
1984
+ stopTracking()
1985
+ }
1986
+ func eddystoneDidEnter(identifier: String, namespace: String, instance: String, distance: Double) {
1987
+ startTracking()
1988
+ }
1989
+ func eddystoneDidExit(identifier: String, namespace: String, instance: String, distance: Double) {
1990
+ stopTracking()
1991
+ }
1992
+ func eddystoneDidTimeout(identifier: String, namespace: String, instance: String, distance: Double) {
1993
+ stopTracking()
1994
+ }
1995
+ // Start tracking when the device connects to CarPlay (wired or wireless),
1996
+ // stop when it disconnects.
1997
+ func carPlayDidConnect(transport: String) {
1998
+ startTracking()
1999
+ }
2000
+ func carPlayDidDisconnect() {
2001
+ stopTracking()
2002
+ }
1811
2003
  }
1812
2004
  ```
1813
2005
 
@@ -1828,19 +2020,35 @@ package com.yourapp
1828
2020
 
1829
2021
  import android.content.Context
1830
2022
  import com.transistorsoft.locationmanager.adapter.BackgroundGeolocation
2023
+ import com.transistorsoft.locationmanager.adapter.callback.TSCallback
1831
2024
  import expo.modules.beacon.BeaconEventPlugin
1832
2025
 
1833
2026
  class BeaconGeoPlugin(ctx: Context) : BeaconEventPlugin {
1834
2027
  private val bgGeo = BackgroundGeolocation.getInstance(ctx, null)
2028
+ private val noOp = object : TSCallback {
2029
+ override fun onSuccess() {}
2030
+ override fun onFailure(error: String) {}
2031
+ }
1835
2032
 
1836
2033
  override fun onBeaconEnter(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) =
1837
- bgGeo.start(null)
2034
+ bgGeo.start(noOp)
1838
2035
  override fun onBeaconExit(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) =
1839
- bgGeo.stop(null)
2036
+ bgGeo.stop(noOp)
2037
+ override fun onBeaconTimeout(identifier: String, uuid: String, major: Int, minor: Int, distance: Double) =
2038
+ bgGeo.stop(noOp)
1840
2039
  override fun onEddystoneEnter(identifier: String, namespace: String, instance: String, distance: Double) =
1841
- bgGeo.start(null)
2040
+ bgGeo.start(noOp)
1842
2041
  override fun onEddystoneExit(identifier: String, namespace: String, instance: String, distance: Double) =
1843
- bgGeo.stop(null)
2042
+ bgGeo.stop(noOp)
2043
+ override fun onEddystoneTimeout(identifier: String, namespace: String, instance: String, distance: Double) =
2044
+ bgGeo.stop(noOp)
2045
+ // Start tracking when the device connects to Android Auto, stop when it disconnects.
2046
+ override fun onCarPlayConnected(transport: String) {
2047
+ bgGeo.start(noOp)
2048
+ }
2049
+ override fun onCarPlayDisconnected() {
2050
+ bgGeo.stop(noOp)
2051
+ }
1844
2052
  }
1845
2053
  ```
1846
2054
 
@@ -1878,7 +2086,7 @@ override fun onCreate() {
1878
2086
  |---|---|
1879
2087
  | Region monitoring | iOS wakes/relaunches the app on region boundary crossings — even if force-quit. |
1880
2088
  | 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` |
2089
+ | Background modes | `allowsBackgroundLocationUpdates` is only enabled when `UIBackgroundModes` contains `location` (the config plugin adds it on prebuild); `pausesLocationUpdatesAutomatically = false` |
1882
2090
  | Region limit | 20 simultaneous `CLBeaconRegion` registrations max. Eddystones don't count. |
1883
2091
 
1884
2092
  ---
@@ -1915,7 +2123,7 @@ Both the foreground service and enter/exit alerts share the channel ID `expo_bea
1915
2123
 
1916
2124
  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
2125
 
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()`.
2126
+ 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
2127
 
1920
2128
  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
2129
 
@@ -1995,6 +2203,7 @@ The module uses hysteresis (3 consecutive readings) to prevent jitter. If you're
1995
2203
  | `INVALID_MINOR` | `pairBeacon` | Minor value not in range 0–65535. |
1996
2204
  | `INVALID_NAMESPACE` | `pairEddystone` | Namespace must be exactly 20 hex characters. |
1997
2205
  | `INVALID_INSTANCE` | `pairEddystone` | Instance must be exactly 12 hex characters. |
2206
+ | `DUPLICATE_IDENTIFIER` | `pairBeacon`, `pairEddystone` | The identifier is already used by a paired beacon of the other type. |
1998
2207
  | `PERMISSION_DENIED` | `scanForBeaconsAsync`, `startMonitoring` | Required permissions were not granted. |
1999
2208
  | `WILDCARD_NOT_SUPPORTED` | `scanForBeaconsAsync` | iOS only: no UUIDs provided and no paired beacons exist. |
2000
2209
 
@@ -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" />