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 +189 -33
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +164 -37
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +101 -12
- package/build/ExpoBeacon.types.d.ts +58 -10
- package/build/ExpoBeacon.types.d.ts.map +1 -1
- package/build/ExpoBeacon.types.js.map +1 -1
- package/build/ExpoBeaconModule.d.ts +1 -45
- package/build/ExpoBeaconModule.d.ts.map +1 -1
- package/build/ExpoBeaconModule.js +9 -1
- package/build/ExpoBeaconModule.js.map +1 -1
- package/build/ExpoBeaconModule.web.d.ts +1 -1
- package/build/ExpoBeaconModule.web.d.ts.map +1 -1
- package/build/ExpoBeaconModule.web.js +1 -1
- package/build/ExpoBeaconModule.web.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/ios/ExpoBeacon.podspec +1 -1
- package/ios/ExpoBeaconModule.swift +416 -76
- package/package.json +1 -1
- package/src/ExpoBeacon.types.ts +63 -11
- package/src/ExpoBeaconModule.ts +34 -7
- package/src/ExpoBeaconModule.web.ts +4 -2
- package/src/index.ts +6 -1
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
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
|
171
|
-
| `
|
|
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
|
-
|
|
177
|
-
|
|
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(
|
|
302
|
+
### `startMonitoring(options?)`
|
|
280
303
|
|
|
281
304
|
```ts
|
|
282
|
-
startMonitoring(
|
|
305
|
+
startMonitoring(options?: MonitoringOptions | number): Promise<void>
|
|
283
306
|
```
|
|
284
307
|
|
|
285
308
|
Starts background region monitoring for all paired beacons.
|
|
286
309
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) | `
|
|
502
|
-
| Enter / exit alerts | `
|
|
657
|
+
| Foreground service (Android) | configurable (default `low`) |
|
|
658
|
+
| Enter / exit alerts | configurable (default `default`) |
|
|
503
659
|
|
|
504
|
-
Both
|
|
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!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
212
|
-
enteredRegions.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
235
|
-
val
|
|
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(
|
|
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
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
268
|
-
.setContentTitle(
|
|
269
|
-
.setContentText(
|
|
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
|
}
|