expo-beacon 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ An Expo module for scanning, pairing, and monitoring iBeacons in React Native ap
11
11
  | Platform | Native implementation |
12
12
  | -------- | --------------------------------------------------------------------------------------------------------- |
13
13
  | Android | [AltBeacon](https://altbeacon.github.io/android-beacon-library/) (`org.altbeacon:android-beacon-library`) |
14
- | iOS | CoreLocation / `CLLocationManager` (iBeacon native API) |
14
+ | iOS | CoreLocation (UUID-targeted scans & monitoring) + CoreBluetooth (wildcard scanning) |
15
15
  | Web | Not supported (throws on all calls) |
16
16
 
17
17
  ---
@@ -47,6 +47,8 @@ In Xcode under **Signing & Capabilities**, enable:
47
47
  - **Background Modes → Uses Bluetooth LE accessories**
48
48
 
49
49
  > iOS limits apps to **20 simultaneously monitored regions**.
50
+ >
51
+ > **Wildcard scanning**: When you call `scanForBeaconsAsync()` with an empty or omitted `uuids` array, iOS uses CoreBluetooth raw BLE scanning to discover all nearby iBeacons. This works **in the foreground only** — it is an Apple platform limitation. UUID-targeted scans and background monitoring continue to use CoreLocation.
50
52
 
51
53
  ### Android
52
54
 
@@ -111,7 +113,8 @@ export default function App() {
111
113
  }, []);
112
114
 
113
115
  async function scan() {
114
- const results = await ExpoBeacon.scanForBeaconsAsync(5000);
116
+ // Wildcard scan discovers all nearby iBeacons (no UUID filter)
117
+ const results = await ExpoBeacon.scanForBeaconsAsync([], 5000);
115
118
  setBeacons(results);
116
119
  }
117
120
 
@@ -158,23 +161,41 @@ if (!granted) {
158
161
 
159
162
  ---
160
163
 
161
- ### `scanForBeaconsAsync(scanDurationMs?)`
164
+ ### `scanForBeaconsAsync(uuids?, scanDurationMs?)`
162
165
 
163
166
  ```ts
164
- scanForBeaconsAsync(scanDurationMs?: number): Promise<BeaconScanResult[]>
167
+ scanForBeaconsAsync(uuids?: string[], scanDurationMs?: number): Promise<BeaconScanResult[]>
165
168
  ```
166
169
 
167
170
  Starts a **one-shot BLE scan**, waits for `scanDurationMs` milliseconds, then resolves with all beacons discovered during that window.
168
171
 
169
- | Parameter | Type | Default | Description |
170
- | --------------- | -------- | ------- | ---------------------------------------- |
171
- | `scanDurationMs`| `number` | `5000` | How long to scan in milliseconds (1–60 000 recommended) |
172
+ | Parameter | Type | Default | Description |
173
+ | ---------------- | ---------- | ------- | ---------------------------------------- |
174
+ | `uuids` | `string[]` | `[]` | Proximity UUIDs to filter by. Pass `[]` or omit for a **wildcard scan** that discovers all nearby iBeacons. |
175
+ | `scanDurationMs` | `number` | `5000` | How long to scan in milliseconds (1–60 000 recommended) |
172
176
 
173
177
  Returns an array of [`BeaconScanResult`](#beaconscanresult) objects. Rejects with `SCAN_IN_PROGRESS` if another scan is already running.
174
178
 
179
+ **Wildcard vs. targeted scans**
180
+
181
+ | | Wildcard (`[]` / omitted) | Targeted (`['UUID-1', …]`) |
182
+ |---|---|---|
183
+ | **Android** | Discovers all iBeacons via AltBeacon | Filters results to matching UUIDs |
184
+ | **iOS** | CoreBluetooth raw BLE scan (**foreground only**) | CoreLocation ranging (works in background) |
185
+
186
+ > **iOS limitation**: Wildcard scanning uses CoreBluetooth, which cannot scan in the background. If your app is backgrounded during a wildcard scan, no new beacons will be discovered. Use UUID-targeted scans or `startMonitoring()` for background beacon detection.
187
+
175
188
  ```ts
176
- const beacons = await ExpoBeacon.scanForBeaconsAsync(8000); // 8-second scan
177
- beacons.forEach((b) =>
189
+ // Wildcard scan discover all nearby iBeacons
190
+ const all = await ExpoBeacon.scanForBeaconsAsync([], 5000);
191
+
192
+ // Targeted scan — only beacons matching these UUIDs
193
+ const filtered = await ExpoBeacon.scanForBeaconsAsync(
194
+ ['E2C56DB5-DFFB-48D2-B060-D0F5A71096E0'],
195
+ 8000,
196
+ );
197
+
198
+ filtered.forEach((b) =>
178
199
  console.log(`${b.uuid} major=${b.major} minor=${b.minor} dist=${b.distance.toFixed(1)}m rssi=${b.rssi}dBm`)
179
200
  );
180
201
  ```
@@ -191,6 +212,8 @@ Begins a **continuous BLE scan** that fires an [`onBeaconFound`](#onbeaconfound)
191
212
 
192
213
  Unlike `scanForBeaconsAsync`, this never resolves — it streams results in real time.
193
214
 
215
+ > **iOS note**: Due to CoreLocation API constraints, `startContinuousScan()` on iOS only ranges beacons that have been previously paired with `pairBeacon()`. On Android, all nearby BLE beacons are reported regardless of pairing status.
216
+
194
217
  ```ts
195
218
  const sub = ExpoBeacon.addListener("onBeaconFound", (beacon) => {
196
219
  console.log("Live:", beacon.uuid, beacon.distance);
@@ -276,28 +299,46 @@ paired.forEach((b) =>
276
299
 
277
300
  ---
278
301
 
279
- ### `startMonitoring(maxDistance?)`
302
+ ### `startMonitoring(options?)`
280
303
 
281
304
  ```ts
282
- startMonitoring(maxDistance?: number): Promise<void>
305
+ startMonitoring(options?: MonitoringOptions | number): Promise<void>
283
306
  ```
284
307
 
285
308
  Starts background region monitoring for all paired beacons.
286
309
 
287
- | Parameter | Type | Default | Description |
288
- | ------------- | -------- | ------------ | --------------------------------------------------------------------------------------------- |
289
- | `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. |
310
+ Accepts either a `MonitoringOptions` object or a plain `number` (backward-compatible shorthand for `maxDistance`).
311
+
312
+ **`MonitoringOptions`**
313
+
314
+ | Property | Type | Default | Description |
315
+ | --------------- | -------------------- | ----------- | --------------------------------------------------------------------------------------------- |
316
+ | `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. |
317
+ | `notifications` | `NotificationConfig` | `undefined` | Notification config overrides applied for this session. Persisted and takes effect immediately. |
290
318
 
291
319
  **Android**: Launches `BeaconForegroundService` — a persistent foreground service required by Android 8+ for background BLE. Restarts automatically after device reboot.
292
320
 
293
321
  **iOS**: Activates `CLLocationManager` region monitoring. iOS can wake or relaunch the app when a region boundary is crossed, even if the app is terminated.
294
322
 
295
323
  ```ts
296
- // Monitor with no distance limit
297
- await ExpoBeacon.startMonitoring();
298
-
299
- // Only fire enter events when within 5 metres
324
+ // Backward-compatible shorthand (number = maxDistance)
300
325
  await ExpoBeacon.startMonitoring(5);
326
+
327
+ // Full options object
328
+ await ExpoBeacon.startMonitoring({
329
+ maxDistance: 5,
330
+ notifications: {
331
+ beaconEvents: {
332
+ enterTitle: "Beacon nearby!",
333
+ body: "{identifier} is within range",
334
+ },
335
+ },
336
+ });
337
+
338
+ // Monitor with no distance limit and no enter/exit notifications
339
+ await ExpoBeacon.startMonitoring({
340
+ notifications: { beaconEvents: { enabled: false } },
341
+ });
301
342
  ```
302
343
 
303
344
  > Call `requestPermissionsAsync()` before `startMonitoring()`.
@@ -318,6 +359,48 @@ await ExpoBeacon.stopMonitoring();
318
359
 
319
360
  ---
320
361
 
362
+ ### `setNotificationConfig(config)`
363
+
364
+ ```ts
365
+ setNotificationConfig(config: NotificationConfig): void
366
+ ```
367
+
368
+ Persists notification configuration that is applied to all subsequent monitoring sessions. Values are stored in `SharedPreferences` (Android) / `UserDefaults` (iOS) and survive app restarts.
369
+
370
+ For one-off overrides tied to a single session, pass `notifications` directly in [`startMonitoring(options)`](#startmonitoringoptions) instead — it calls this function automatically.
371
+
372
+ ```ts
373
+ ExpoBeacon.setNotificationConfig({
374
+ // Enter/exit alert notifications
375
+ beaconEvents: {
376
+ enabled: true, // false to suppress all enter/exit notifications
377
+ enterTitle: "Beacon nearby",
378
+ exitTitle: "Beacon out of range",
379
+ body: "{identifier} {event}ed", // {identifier} and {event} are replaced at runtime
380
+ sound: true, // iOS only — default true
381
+ icon: "ic_beacon_notification", // Android only — drawable resource name
382
+ },
383
+
384
+ // Persistent status-bar notification while monitoring is active (Android only)
385
+ foregroundService: {
386
+ title: "My App is watching",
387
+ text: "Monitoring for nearby beacons",
388
+ icon: "ic_service", // Android drawable resource name
389
+ },
390
+
391
+ // Android notification channel settings
392
+ channel: {
393
+ name: "Proximity Alerts",
394
+ description: "Alerts when beacons enter or leave range",
395
+ importance: "default", // "low" | "default" | "high"
396
+ },
397
+ });
398
+ ```
399
+
400
+ > **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.
401
+
402
+ ---
403
+
321
404
  ## Events
322
405
 
323
406
  Subscribe to events using `ExpoBeacon.addListener(eventName, handler)`. Always call `.remove()` on the subscription when your component unmounts.
@@ -360,15 +443,7 @@ ExpoBeacon.addListener("onBeaconExit", (e) => {
360
443
 
361
444
  ### `onBeaconRanging`
362
445
 
363
- Fired periodically during active monitoring with the latest ranging measurement for a paired beacon.
364
-
365
- **Payload**: [`BeaconRangingEvent`](#beaconrangingevent)
366
-
367
- ```ts
368
- ExpoBeacon.addListener("onBeaconRanging", (e) => {
369
- console.log(`Ranging ${e.identifier}: ${e.distance.toFixed(2)} m rssi=${e.rssi}`);
370
- });
371
- ```
446
+ > **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.
372
447
 
373
448
  ---
374
449
 
@@ -447,7 +522,7 @@ type BeaconRegionEvent = {
447
522
 
448
523
  ### `BeaconRangingEvent`
449
524
 
450
- Payload for `onBeaconRanging`.
525
+ 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.
451
526
 
452
527
  ```ts
453
528
  type BeaconRangingEvent = {
@@ -476,6 +551,71 @@ type BeaconDistanceEvent = {
476
551
 
477
552
  ---
478
553
 
554
+ ### `MonitoringOptions`
555
+
556
+ Passed to `startMonitoring(options)`.
557
+
558
+ ```ts
559
+ type MonitoringOptions = {
560
+ maxDistance?: number; // Distance threshold in metres for enter events
561
+ notifications?: NotificationConfig; // Notification overrides for this session
562
+ };
563
+ ```
564
+
565
+ ### `NotificationConfig`
566
+
567
+ Top-level config object accepted by `setNotificationConfig()` and `startMonitoring({ notifications })`.
568
+
569
+ ```ts
570
+ type NotificationConfig = {
571
+ beaconEvents?: BeaconNotificationConfig;
572
+ foregroundService?: ForegroundServiceConfig; // Android only
573
+ channel?: NotificationChannelConfig; // Android only
574
+ };
575
+ ```
576
+
577
+ ### `BeaconNotificationConfig`
578
+
579
+ Controls the enter/exit alert notifications.
580
+
581
+ ```ts
582
+ type BeaconNotificationConfig = {
583
+ enabled?: boolean; // false to disable all enter/exit notifications. Default: true
584
+ enterTitle?: string; // Default: "Beacon Entered"
585
+ exitTitle?: string; // Default: "Beacon Exited"
586
+ body?: string; // Template — {identifier} and {event} are replaced at runtime
587
+ // Default: "{identifier} region {event}ed"
588
+ sound?: boolean; // iOS only — play notification sound. Default: true
589
+ icon?: string; // Android only — drawable resource name (e.g. "ic_notification")
590
+ };
591
+ ```
592
+
593
+ ### `ForegroundServiceConfig`
594
+
595
+ Controls the persistent Android status-bar notification while monitoring is active.
596
+
597
+ ```ts
598
+ type ForegroundServiceConfig = {
599
+ title?: string; // Default: "Beacon Monitoring Active"
600
+ text?: string; // Default: "Monitoring for iBeacons in the background"
601
+ icon?: string; // Android drawable resource name
602
+ };
603
+ ```
604
+
605
+ ### `NotificationChannelConfig`
606
+
607
+ Controls the Android notification channel shown in system settings.
608
+
609
+ ```ts
610
+ type NotificationChannelConfig = {
611
+ name?: string; // Default: "Beacon Monitoring"
612
+ description?: string; // Default: "Used for background iBeacon region monitoring"
613
+ importance?: "low" | "default" | "high"; // Default: "low"
614
+ };
615
+ ```
616
+
617
+ ---
618
+
479
619
  ## Background Behaviour
480
620
 
481
621
  ### Android
@@ -494,20 +634,36 @@ Default scan timing: 1.1 s scan window every 5 s.
494
634
 
495
635
  ## Notifications
496
636
 
497
- A local notification is posted for every `onBeaconEnter` and `onBeaconExit` event.
637
+ A local notification is posted for every `onBeaconEnter` and `onBeaconExit` event. All notification settings can be customised via [`setNotificationConfig()`](#setnotificationconfigconfig) or inline in [`startMonitoring(options)`](#startmonitoringoptions).
638
+
639
+ ### Defaults
640
+
641
+ | Property | Default value |
642
+ | ------------------------------ | ------------------------------------------------ |
643
+ | Enter title | `"Beacon Entered"` |
644
+ | Exit title | `"Beacon Exited"` |
645
+ | Body | `"{identifier} region {event}ed"` |
646
+ | Sound (iOS) | `true` |
647
+ | Icon (Android) | System `ic_dialog_info` |
648
+ | Foreground service title | `"Beacon Monitoring Active"` |
649
+ | Foreground service text | `"Monitoring for iBeacons in the background"` |
650
+ | Channel name (Android) | `"Beacon Monitoring"` |
651
+ | Channel importance (Android) | `"low"` |
652
+
653
+ ### Channel IDs (Android)
498
654
 
499
655
  | Channel / type | Importance |
500
656
  | ------------------------------- | ------------------- |
501
- | Foreground service (Android) | `IMPORTANCE_LOW` |
502
- | Enter / exit alerts | `IMPORTANCE_DEFAULT`|
657
+ | Foreground service (Android) | configurable (default `low`) |
658
+ | Enter / exit alerts | configurable (default `default`) |
503
659
 
504
- Both channels share the id `expo_beacon_channel`.
660
+ 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.
505
661
 
506
662
  ---
507
663
 
508
664
  ## Contributing
509
665
 
510
- Contributions are welcome! Please refer to the [contributing guide](https://github.com/expo/expo#contributing).
666
+ Contributions are welcome! Open an issue or pull request on [GitHub](https://github.com/martinmikesCCS/expo-beacon).
511
667
 
512
668
  ## License
513
669
 
@@ -18,7 +18,12 @@ private const val PREFS_NAME = "expo.beacon.paired"
18
18
  private const val PREFS_KEY = "paired_beacons"
19
19
  private const val CHANNEL_ID = "expo_beacon_channel"
20
20
  private const val FOREGROUND_NOTIF_ID = 1001
21
- private const val ENTER_EXIT_NOTIF_ID = 1002
21
+ private const val ENTER_EXIT_NOTIF_BASE_ID = 2000
22
+
23
+ /// Number of consecutive ranging misses before emitting a distance-based exit event.
24
+ private const val EXIT_MISS_THRESHOLD = 3
25
+ /// Number of consecutive readings required to confirm a distance-based enter or exit transition.
26
+ private const val HYSTERESIS_COUNT = 3
22
27
 
23
28
  class BeaconForegroundService : Service(), BeaconConsumer {
24
29
 
@@ -30,6 +35,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
30
35
  private val rangingRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
31
36
  private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
32
37
 
38
+ // Hysteresis counters (synchronized on distanceLock)
39
+ private val distanceLock = Any()
40
+ private val enterCounters = java.util.concurrent.ConcurrentHashMap<String, Int>()
41
+ private val exitCounters = java.util.concurrent.ConcurrentHashMap<String, Int>()
42
+ private val missCounters = java.util.concurrent.ConcurrentHashMap<String, Int>()
43
+
44
+ // Notification ID counter for unique per-beacon notifications
45
+ private var notifIdCounter = 0
46
+ private val notifIdMap = java.util.concurrent.ConcurrentHashMap<String, Int>()
47
+
33
48
  // Distance logging
34
49
  private val distanceLogRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
35
50
 
@@ -70,7 +85,8 @@ class BeaconForegroundService : Service(), BeaconConsumer {
70
85
  )
71
86
  }
72
87
  // Use continuous scanning (not JobScheduler) for foreground service
73
- manager.setEnableScheduledScanJobs(false)
88
+ // Guard: throws if called after ranging/monitoring has already started
89
+ try { manager.setEnableScheduledScanJobs(false) } catch (_: IllegalStateException) {}
74
90
  manager.setBackgroundBetweenScanPeriod(5000L) // 5s between scans
75
91
  manager.setBackgroundScanPeriod(1100L) // 1.1s scan window
76
92
  manager.setForegroundScanPeriod(1000L) // 1s scan window for distance logging
@@ -152,26 +168,62 @@ class BeaconForegroundService : Service(), BeaconConsumer {
152
168
  Log.d("BeaconMonitor", "[${region.uniqueId}] distance: ${"%.2f".format(closest.distance)} m rssi=${closest.rssi} txPower=${closest.txPower}")
153
169
  sendBeaconBroadcast(region, "distance", closest.distance)
154
170
 
171
+ // Reset miss counter — we got a valid reading
172
+ missCounters[region.uniqueId] = 0
173
+
155
174
  val maxDist = maxDistance
156
175
  if (maxDist != null) {
157
- if (!enteredRegions.contains(region.uniqueId) && closest.distance <= maxDist) {
158
- // Distance-based entry
159
- Log.d("BeaconMonitor", "[${region.uniqueId}] distance ${closest.distance}m <= maxDistance ${maxDist}m — synthesizing enter")
160
- enteredRegions.add(region.uniqueId)
161
- rangingRegions.remove(region)
162
- sendBeaconBroadcast(region, "enter", closest.distance)
163
- showEnterExitNotification(region, "enter")
164
- } else if (enteredRegions.contains(region.uniqueId) && closest.distance > maxDist) {
165
- // Distance-based exit
166
- Log.d("BeaconMonitor", "[${region.uniqueId}] distance ${closest.distance}m > maxDistance ${maxDist}m — synthesizing exit")
167
- enteredRegions.remove(region.uniqueId)
168
- rangingRegions.add(region)
169
- sendBeaconBroadcast(region, "exit", closest.distance)
170
- showEnterExitNotification(region, "exit")
176
+ synchronized(distanceLock) {
177
+ if (closest.distance <= maxDist) {
178
+ // Reading is inside threshold
179
+ exitCounters[region.uniqueId] = 0
180
+ val count = (enterCounters[region.uniqueId] ?: 0) + 1
181
+ enterCounters[region.uniqueId] = count
182
+
183
+ if (!enteredRegions.contains(region.uniqueId) && count >= HYSTERESIS_COUNT) {
184
+ enteredRegions.add(region.uniqueId)
185
+ enterCounters[region.uniqueId] = 0
186
+ rangingRegions.remove(region)
187
+ sendBeaconBroadcast(region, "enter", closest.distance)
188
+ showEnterExitNotification(region, "enter")
189
+ }
190
+ } else {
191
+ // Reading is outside threshold
192
+ enterCounters[region.uniqueId] = 0
193
+ val count = (exitCounters[region.uniqueId] ?: 0) + 1
194
+ exitCounters[region.uniqueId] = count
195
+
196
+ if (enteredRegions.contains(region.uniqueId) && count >= HYSTERESIS_COUNT) {
197
+ enteredRegions.remove(region.uniqueId)
198
+ exitCounters[region.uniqueId] = 0
199
+ rangingRegions.add(region)
200
+ sendBeaconBroadcast(region, "exit", closest.distance)
201
+ showEnterExitNotification(region, "exit")
202
+ }
203
+ }
171
204
  }
172
205
  }
173
206
  } else {
174
207
  Log.d("BeaconMonitor", "[${region.uniqueId}] no beacons in range")
208
+
209
+ // Beacon may have disappeared — track consecutive misses
210
+ val maxDist = maxDistance
211
+ if (maxDist != null) {
212
+ val count = (missCounters[region.uniqueId] ?: 0) + 1
213
+ missCounters[region.uniqueId] = count
214
+
215
+ if (enteredRegions.contains(region.uniqueId) && count >= EXIT_MISS_THRESHOLD) {
216
+ synchronized(distanceLock) {
217
+ if (enteredRegions.remove(region.uniqueId)) {
218
+ missCounters[region.uniqueId] = 0
219
+ enterCounters[region.uniqueId] = 0
220
+ exitCounters[region.uniqueId] = 0
221
+ sendBeaconBroadcast(region, "exit", -1.0)
222
+ showEnterExitNotification(region, "exit")
223
+ }
224
+ }
225
+ }
226
+ }
175
227
  }
176
228
  }
177
229
 
@@ -189,8 +241,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
189
241
  }
190
242
 
191
243
  override fun didExitRegion(region: Region) {
192
- // Remove from confirmation tracking (ranging stays active for distance logging)
193
244
  rangingRegions.remove(region)
245
+
246
+ if (maxDistance != null) {
247
+ // When maxDistance is set, distance ranging handles exit events.
248
+ // Just clean up tracked state so distance-driven exit can fire.
249
+ enteredRegions.remove(region.uniqueId)
250
+ return
251
+ }
252
+
194
253
  enteredRegions.remove(region.uniqueId)
195
254
  sendBeaconBroadcast(region, "exit", -1.0)
196
255
  showEnterExitNotification(region, "exit")
@@ -208,12 +267,19 @@ class BeaconForegroundService : Service(), BeaconConsumer {
208
267
  .filter { it.distance >= 0 }
209
268
  .minByOrNull { it.distance } ?: return@RangeNotifier
210
269
 
211
- if (beacon.distance <= maxDist && !enteredRegions.contains(region.uniqueId)) {
212
- enteredRegions.add(region.uniqueId)
213
- // Remove from confirmation tracking (ranging stays active for distance logging)
214
- rangingRegions.remove(region)
215
- sendBeaconBroadcast(region, "enter", beacon.distance)
216
- showEnterExitNotification(region, "enter")
270
+ synchronized(distanceLock) {
271
+ if (beacon.distance <= maxDist && !enteredRegions.contains(region.uniqueId)) {
272
+ val count = (enterCounters[region.uniqueId] ?: 0) + 1
273
+ enterCounters[region.uniqueId] = count
274
+
275
+ if (count >= HYSTERESIS_COUNT) {
276
+ enteredRegions.add(region.uniqueId)
277
+ enterCounters[region.uniqueId] = 0
278
+ rangingRegions.remove(region)
279
+ sendBeaconBroadcast(region, "enter", beacon.distance)
280
+ showEnterExitNotification(region, "enter")
281
+ }
282
+ }
217
283
  }
218
284
  }
219
285
 
@@ -231,11 +297,33 @@ class BeaconForegroundService : Service(), BeaconConsumer {
231
297
  }
232
298
 
233
299
  private fun showEnterExitNotification(region: Region, eventType: String) {
234
- val title = if (eventType == "enter") "Beacon Entered" else "Beacon Exited"
235
- val message = "${region.uniqueId} region ${eventType}ed"
300
+ val config = readNotificationConfig()
301
+ val eventsConfig = config.optJSONObject("beaconEvents")
302
+
303
+ // Respect the enabled flag (defaults to true)
304
+ if (eventsConfig != null && !eventsConfig.optBoolean("enabled", true)) return
305
+
306
+ val defaultTitle = if (eventType == "enter") "Beacon Entered" else "Beacon Exited"
307
+ val title = if (eventType == "enter") {
308
+ eventsConfig?.optString("enterTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
309
+ } else {
310
+ eventsConfig?.optString("exitTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
311
+ }
312
+
313
+ val bodyTemplate = eventsConfig?.optString("body")?.takeIf { it.isNotEmpty() }
314
+ ?: "{identifier} region {event}ed"
315
+ val message = bodyTemplate
316
+ .replace("{identifier}", region.uniqueId)
317
+ .replace("{event}", eventType)
318
+
319
+ val iconName = eventsConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
320
+ val iconResId = iconName?.let { name ->
321
+ try { resources.getIdentifier(name, "drawable", packageName).takeIf { it != 0 } }
322
+ catch (_: Exception) { null }
323
+ } ?: android.R.drawable.ic_dialog_info
236
324
 
237
325
  val notification = NotificationCompat.Builder(this, CHANNEL_ID)
238
- .setSmallIcon(android.R.drawable.ic_dialog_info)
326
+ .setSmallIcon(iconResId)
239
327
  .setContentTitle(title)
240
328
  .setContentText(message)
241
329
  .setPriority(NotificationCompat.PRIORITY_DEFAULT)
@@ -243,7 +331,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
243
331
  .build()
244
332
 
245
333
  try {
246
- NotificationManagerCompat.from(this).notify(ENTER_EXIT_NOTIF_ID, notification)
334
+ val notifId = notifIdMap.getOrPut(region.uniqueId) {
335
+ ENTER_EXIT_NOTIF_BASE_ID + notifIdCounter++
336
+ }
337
+ NotificationManagerCompat.from(this).notify(notifId, notification)
247
338
  } catch (_: SecurityException) {
248
339
  // POST_NOTIFICATIONS not granted — silently skip notification
249
340
  }
@@ -251,27 +342,59 @@ class BeaconForegroundService : Service(), BeaconConsumer {
251
342
 
252
343
  private fun createNotificationChannel() {
253
344
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
254
- val channel = NotificationChannel(
255
- CHANNEL_ID,
256
- "Beacon Monitoring",
257
- NotificationManager.IMPORTANCE_LOW
258
- ).apply {
259
- description = "Used for background iBeacon region monitoring"
345
+ val config = readNotificationConfig()
346
+ val channelConfig = config.optJSONObject("channel")
347
+
348
+ val channelName = channelConfig?.optString("name")?.takeIf { it.isNotEmpty() }
349
+ ?: "Beacon Monitoring"
350
+ val channelDesc = channelConfig?.optString("description")?.takeIf { it.isNotEmpty() }
351
+ ?: "Used for background iBeacon region monitoring"
352
+ val importance = when (channelConfig?.optString("importance")) {
353
+ "high" -> NotificationManager.IMPORTANCE_HIGH
354
+ "default" -> NotificationManager.IMPORTANCE_DEFAULT
355
+ else -> NotificationManager.IMPORTANCE_LOW
356
+ }
357
+
358
+ val notifMgr = getSystemService(NotificationManager::class.java)
359
+ // Only create channel if it doesn't exist yet — preserves user notification preferences
360
+ if (notifMgr?.getNotificationChannel(CHANNEL_ID) == null) {
361
+ val channel = NotificationChannel(CHANNEL_ID, channelName, importance).apply {
362
+ description = channelDesc
363
+ }
364
+ notifMgr?.createNotificationChannel(channel)
260
365
  }
261
- getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
262
366
  }
263
367
  }
264
368
 
265
369
  private fun buildForegroundNotification(): Notification {
370
+ val config = readNotificationConfig()
371
+ val fgConfig = config.optJSONObject("foregroundService")
372
+
373
+ val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() }
374
+ ?: "Beacon Monitoring Active"
375
+ val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() }
376
+ ?: "Monitoring for iBeacons in the background"
377
+ val iconName = fgConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
378
+ val iconResId = iconName?.let { name ->
379
+ try { resources.getIdentifier(name, "drawable", packageName).takeIf { it != 0 } }
380
+ catch (_: Exception) { null }
381
+ } ?: android.R.drawable.ic_dialog_info
382
+
266
383
  return NotificationCompat.Builder(this, CHANNEL_ID)
267
- .setSmallIcon(android.R.drawable.ic_dialog_info)
268
- .setContentTitle("Beacon Monitoring Active")
269
- .setContentText("Monitoring for iBeacons in the background")
384
+ .setSmallIcon(iconResId)
385
+ .setContentTitle(title)
386
+ .setContentText(text)
270
387
  .setPriority(NotificationCompat.PRIORITY_LOW)
271
388
  .setOngoing(true)
272
389
  .build()
273
390
  }
274
391
 
392
+ private fun readNotificationConfig(): org.json.JSONObject {
393
+ val json = getSharedPreferences("expo.beacon.notification_config", Context.MODE_PRIVATE)
394
+ .getString("config", null) ?: return org.json.JSONObject()
395
+ return try { org.json.JSONObject(json) } catch (_: Exception) { org.json.JSONObject() }
396
+ }
397
+
275
398
  override fun onDestroy() {
276
399
  beaconManager.removeMonitorNotifier(monitorNotifier)
277
400
  beaconManager.removeRangeNotifier(rangeNotifier)
@@ -285,6 +408,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
285
408
  }
286
409
  distanceLogRegions.clear()
287
410
  enteredRegions.clear()
411
+ enterCounters.clear()
412
+ exitCounters.clear()
413
+ missCounters.clear()
414
+ notifIdMap.clear()
288
415
  monitoredRegions.forEach {
289
416
  try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
290
417
  }