expo-beacon 0.5.6 → 0.6.2

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
@@ -8,6 +8,8 @@ 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 |
12
+ | **Event Logging** | Persist every beacon event to a local SQLite database for diagnostics & replay |
11
13
  | **Notifications** | Automatic local notifications on region enter/exit, fully customisable |
12
14
 
13
15
  | Platform | Native Implementation |
@@ -32,6 +34,8 @@ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddyston
32
34
  - [Pairing & Unpairing Beacons](#pairing--unpairing-beacons)
33
35
  - [Background Monitoring](#background-monitoring)
34
36
  - [Customizing Notifications](#customizing-notifications)
37
+ - [Beacon Timeout](#beacon-timeout)
38
+ - [Event Logging](#event-logging)
35
39
  - [Cancelling a Scan](#cancelling-a-scan)
36
40
  - [Full API Reference](#full-api-reference)
37
41
  - [requestPermissionsAsync()](#requestpermissionsasync)
@@ -49,6 +53,11 @@ An Expo module for scanning, pairing, and monitoring **iBeacons** and **Eddyston
49
53
  - [startMonitoring()](#startmonitoringoptions)
50
54
  - [stopMonitoring()](#stopmonitoring)
51
55
  - [setNotificationConfig()](#setnotificationconfigconfig)
56
+ - [enableEventLogging()](#enableeventlogging)
57
+ - [disableEventLogging()](#disableeventlogging)
58
+ - [getEventLogs()](#geteventlogsoptions)
59
+ - [clearEventLogs()](#cleareventlogs)
60
+ - [destroyEventLogs()](#destroyeventlogs)
52
61
  - [Events](#events)
53
62
  - [TypeScript Types](#typescript-types)
54
63
  - [Background Behaviour](#background-behaviour)
@@ -551,6 +560,103 @@ await ExpoBeacon.startMonitoring({
551
560
 
552
561
  ---
553
562
 
563
+ ### Beacon Timeout
564
+
565
+ 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.
566
+
567
+ ```tsx
568
+ import { useEffect } from "react";
569
+ import ExpoBeacon from "expo-beacon";
570
+ import type { BeaconTimeoutEvent, EddystoneTimeoutEvent } from "expo-beacon";
571
+
572
+ // Pair with a 30-second timeout
573
+ ExpoBeacon.pairBeacon(
574
+ "lobby-entrance",
575
+ "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0",
576
+ 1,
577
+ 100,
578
+ undefined, // name (optional)
579
+ 30, // timeoutSeconds — fires after 30 s in range
580
+ );
581
+
582
+ // Pair Eddystone with a 60-second timeout
583
+ ExpoBeacon.pairEddystone(
584
+ "meeting-room",
585
+ "edd1ebeac04e5defa017",
586
+ "0123456789ab",
587
+ undefined, // name (optional)
588
+ 60, // timeoutSeconds — fires after 60 s in range
589
+ );
590
+
591
+ // Listen for the timeout events
592
+ useEffect(() => {
593
+ const beaconTimeout = ExpoBeacon.addListener(
594
+ "onBeaconTimeout",
595
+ (e: BeaconTimeoutEvent) => {
596
+ console.log(`Beacon "${e.identifier}" in range for configured duration! dist: ${e.distance.toFixed(1)}m`);
597
+ },
598
+ );
599
+ const eddystoneTimeout = ExpoBeacon.addListener(
600
+ "onEddystoneTimeout",
601
+ (e: EddystoneTimeoutEvent) => {
602
+ console.log(`Eddystone "${e.identifier}" in range for configured duration!`);
603
+ },
604
+ );
605
+
606
+ return () => {
607
+ beaconTimeout.remove();
608
+ eddystoneTimeout.remove();
609
+ };
610
+ }, []);
611
+ ```
612
+
613
+ > **Note**: The timeout fires once per enter cycle. If the beacon exits and re-enters range, the timer starts over.
614
+
615
+ ---
616
+
617
+ ### Event Logging
618
+
619
+ Enable SQLite-backed event logging to persist every beacon event locally. Useful for diagnostics, debugging, and replaying event history.
620
+
621
+ ```ts
622
+ import ExpoBeacon from "expo-beacon";
623
+ import type { EventLogEntry, EventLogQueryOptions } from "expo-beacon";
624
+
625
+ // Enable logging — creates/opens the SQLite database
626
+ ExpoBeacon.enableEventLogging();
627
+
628
+ // ... scanning, monitoring, etc. — all events are now persisted automatically ...
629
+
630
+ // Query all recent events
631
+ const logs: EventLogEntry[] = ExpoBeacon.getEventLogs();
632
+ console.log(logs);
633
+ // [
634
+ // { id: 42, timestamp: 1712345678000, eventType: "onBeaconEnter",
635
+ // identifier: "lobby", data: { uuid: "E2C5…", major: 1, minor: 100, ... } },
636
+ // ...
637
+ // ]
638
+
639
+ // Filter by event type and time range
640
+ const enterLogs = ExpoBeacon.getEventLogs({
641
+ eventType: "onBeaconEnter",
642
+ sinceTimestamp: Date.now() - 3600_000, // last hour
643
+ limit: 100,
644
+ });
645
+
646
+ // Disable logging (retains existing data)
647
+ ExpoBeacon.disableEventLogging();
648
+
649
+ // Clear all logged events (keeps the database)
650
+ ExpoBeacon.clearEventLogs();
651
+
652
+ // Destroy the database entirely (also disables logging)
653
+ ExpoBeacon.destroyEventLogs();
654
+ ```
655
+
656
+ > **Storage**: Events are stored in a local SQLite database (`expo_beacon_events.db`). No external dependencies are required — Android uses the built-in SQLite, iOS uses the system `libsqlite3`.
657
+
658
+ ---
659
+
554
660
  ### Cancelling a Scan
555
661
 
556
662
  Cancel any in-progress one-shot scan (iBeacon or Eddystone). The pending promise will reject with error code `SCAN_CANCELLED`.
@@ -708,10 +814,10 @@ Cancels any in-progress one-shot scan (iBeacon or Eddystone). The pending promis
708
814
 
709
815
  ---
710
816
 
711
- ### `pairBeacon(identifier, uuid, major, minor)`
817
+ ### `pairBeacon(identifier, uuid, major, minor, name?, timeoutSeconds?)`
712
818
 
713
819
  ```ts
714
- pairBeacon(identifier: string, uuid: string, major: number, minor: number): void
820
+ pairBeacon(identifier: string, uuid: string, major: number, minor: number, name?: string, timeoutSeconds?: number): void
715
821
  ```
716
822
 
717
823
  Registers an iBeacon for persistent monitoring.
@@ -722,11 +828,16 @@ Registers an iBeacon for persistent monitoring.
722
828
  | `uuid` | `string` | iBeacon proximity UUID (case-insensitive, e.g. `"E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"`) |
723
829
  | `major` | `number` | Major value: `0`–`65535` |
724
830
  | `minor` | `number` | Minor value: `0`–`65535` |
831
+ | `name` | `string?` | Optional BLE device name for display purposes |
832
+ | `timeoutSeconds` | `number?` | Fire `onBeaconTimeout` once after the beacon stays in range this many seconds. Timer resets on exit/re-enter. |
725
833
 
726
834
  **Possible errors**: `INVALID_UUID`, `INVALID_MAJOR`, `INVALID_MINOR`.
727
835
 
728
836
  ```ts
729
837
  ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42);
838
+
839
+ // With timeout — fires onBeaconTimeout after 30 s in range
840
+ ExpoBeacon.pairBeacon("main-door", "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0", 1, 42, undefined, 30);
730
841
  ```
731
842
 
732
843
  ---
@@ -764,10 +875,10 @@ const paired = ExpoBeacon.getPairedBeacons();
764
875
 
765
876
  ---
766
877
 
767
- ### `pairEddystone(identifier, namespace, instance)`
878
+ ### `pairEddystone(identifier, namespace, instance, name?, timeoutSeconds?)`
768
879
 
769
880
  ```ts
770
- pairEddystone(identifier: string, namespace: string, instance: string): void
881
+ pairEddystone(identifier: string, namespace: string, instance: string, name?: string, timeoutSeconds?: number): void
771
882
  ```
772
883
 
773
884
  Registers an Eddystone-UID beacon for persistent monitoring.
@@ -777,11 +888,16 @@ Registers an Eddystone-UID beacon for persistent monitoring.
777
888
  | `identifier` | `string` | Unique label (e.g. `"meeting-room"`) |
778
889
  | `namespace` | `string` | 10-byte namespace ID as hex string — must be exactly **20 hex characters** |
779
890
  | `instance` | `string` | 6-byte instance ID as hex string — must be exactly **12 hex characters** |
891
+ | `name` | `string?` | Optional BLE device name for display purposes |
892
+ | `timeoutSeconds` | `number?` | Fire `onEddystoneTimeout` once after the beacon stays in range this many seconds. Timer resets on exit/re-enter. |
780
893
 
781
894
  **Possible errors**: `INVALID_NAMESPACE`, `INVALID_INSTANCE`.
782
895
 
783
896
  ```ts
784
897
  ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab");
898
+
899
+ // With timeout — fires onEddystoneTimeout after 60 s in range
900
+ ExpoBeacon.pairEddystone("meeting-room", "edd1ebeac04e5defa017", "0123456789ab", undefined, 60);
785
901
  ```
786
902
 
787
903
  ---
@@ -897,27 +1013,82 @@ For one-off overrides, pass `notifications` inside `startMonitoring(options)` in
897
1013
 
898
1014
  See [`NotificationConfig`](#notificationconfig) for the full shape.
899
1015
 
1016
+ ---
1017
+
1018
+ ### `enableEventLogging()`
1019
+
900
1020
  ```ts
901
- ExpoBeacon.setNotificationConfig({
902
- beaconEvents: {
903
- enabled: true,
904
- enterTitle: "Nearby",
905
- exitTitle: "Gone",
906
- body: "{identifier} {event}ed",
907
- sound: true,
908
- icon: "ic_notification",
909
- },
910
- foregroundService: {
911
- title: "Monitoring Active",
912
- text: "Scanning for beacons",
913
- icon: "ic_service",
914
- },
915
- channel: {
916
- name: "Beacons",
917
- description: "Beacon proximity alerts",
918
- importance: "default",
919
- },
920
- });
1021
+ enableEventLogging(): void
1022
+ ```
1023
+
1024
+ Creates/opens the local SQLite database and starts persisting **every beacon event** (`onBeaconEnter`, `onBeaconExit`, `onBeaconDistance`, `onBeaconTimeout`, `onBeaconFound`, `onEddystoneEnter`, etc.). Call before `startMonitoring()` or `startContinuousScan()`.
1025
+
1026
+ ```ts
1027
+ ExpoBeacon.enableEventLogging();
1028
+ ```
1029
+
1030
+ ---
1031
+
1032
+ ### `disableEventLogging()`
1033
+
1034
+ ```ts
1035
+ disableEventLogging(): void
1036
+ ```
1037
+
1038
+ Stops persisting events. Previously logged data is **retained** — call `clearEventLogs()` or `destroyEventLogs()` to remove it.
1039
+
1040
+ ```ts
1041
+ ExpoBeacon.disableEventLogging();
1042
+ ```
1043
+
1044
+ ---
1045
+
1046
+ ### `getEventLogs(options?)`
1047
+
1048
+ ```ts
1049
+ getEventLogs(options?: EventLogQueryOptions): EventLogEntry[]
1050
+ ```
1051
+
1052
+ Retrieves logged events from the SQLite database, newest first.
1053
+
1054
+ | Property | Type | Default | Description |
1055
+ |---|---|---|---|
1056
+ | `limit` | `number` | `1000` | Max rows to return (capped at 10 000) |
1057
+ | `eventType` | `string` | `undefined` | Filter by event name (e.g. `"onBeaconEnter"`) |
1058
+ | `sinceTimestamp` | `number` | `undefined` | Only events with `timestamp >= value` (ms since epoch) |
1059
+
1060
+ **Returns**: `EventLogEntry[]`
1061
+
1062
+ ```ts
1063
+ const logs = ExpoBeacon.getEventLogs({ eventType: "onBeaconEnter", limit: 50 });
1064
+ ```
1065
+
1066
+ ---
1067
+
1068
+ ### `clearEventLogs()`
1069
+
1070
+ ```ts
1071
+ clearEventLogs(): void
1072
+ ```
1073
+
1074
+ Deletes all rows from the event log table. The database file remains.
1075
+
1076
+ ```ts
1077
+ ExpoBeacon.clearEventLogs();
1078
+ ```
1079
+
1080
+ ---
1081
+
1082
+ ### `destroyEventLogs()`
1083
+
1084
+ ```ts
1085
+ destroyEventLogs(): void
1086
+ ```
1087
+
1088
+ Disables logging **and** deletes the entire SQLite database file.
1089
+
1090
+ ```ts
1091
+ ExpoBeacon.destroyEventLogs();
921
1092
  ```
922
1093
 
923
1094
  ---
@@ -944,6 +1115,8 @@ sub.remove();
944
1115
  | `onEddystoneEnter` | Paired Eddystone enters range (respects `maxDistance`) | `EddystoneRegionEvent` |
945
1116
  | `onEddystoneExit` | Paired Eddystone leaves range (always fires) | `EddystoneRegionEvent` |
946
1117
  | `onEddystoneDistance` | Periodic Eddystone distance update during monitoring | `EddystoneDistanceEvent` |
1118
+ | `onBeaconTimeout` | Paired iBeacon in range for configured `timeoutSeconds` | `BeaconTimeoutEvent` |
1119
+ | `onEddystoneTimeout` | Paired Eddystone in range for configured `timeoutSeconds` | `EddystoneTimeoutEvent` |
947
1120
 
948
1121
  ### Event Detail
949
1122
 
@@ -1038,6 +1211,31 @@ ExpoBeacon.addListener("onEddystoneDistance", (e) => {
1038
1211
  });
1039
1212
  ```
1040
1213
 
1214
+ #### `onBeaconTimeout`
1215
+
1216
+ Fired **once** when a paired iBeacon has been continuously in range for its configured `timeoutSeconds` duration. The timer resets on exit/re-enter.
1217
+
1218
+ ```ts
1219
+ ExpoBeacon.addListener("onBeaconTimeout", (e) => {
1220
+ // e.identifier — "lobby-entrance"
1221
+ // e.uuid, e.major, e.minor — beacon identity
1222
+ // e.distance — metres at the moment the timeout fired
1223
+ console.log(`Beacon "${e.identifier}" timeout — in range for configured duration`);
1224
+ });
1225
+ ```
1226
+
1227
+ #### `onEddystoneTimeout`
1228
+
1229
+ Fired **once** when a paired Eddystone has been continuously in range for its configured `timeoutSeconds` duration.
1230
+
1231
+ ```ts
1232
+ ExpoBeacon.addListener("onEddystoneTimeout", (e) => {
1233
+ // e.identifier, e.namespace, e.instance — Eddystone identity
1234
+ // e.distance — metres at the moment the timeout fired (–1 if unavailable)
1235
+ console.log(`Eddystone "${e.identifier}" timeout`);
1236
+ });
1237
+ ```
1238
+
1041
1239
  ---
1042
1240
 
1043
1241
  ## TypeScript Types
@@ -1050,17 +1248,21 @@ import type {
1050
1248
  PairedBeacon,
1051
1249
  BeaconRegionEvent,
1052
1250
  BeaconDistanceEvent,
1251
+ BeaconTimeoutEvent,
1053
1252
  EddystoneFrameType,
1054
1253
  EddystoneScanResult,
1055
1254
  PairedEddystone,
1056
1255
  EddystoneRegionEvent,
1057
1256
  EddystoneDistanceEvent,
1257
+ EddystoneTimeoutEvent,
1058
1258
  ExpoBeaconModuleEvents,
1059
1259
  MonitoringOptions,
1060
1260
  NotificationConfig,
1061
1261
  BeaconNotificationConfig,
1062
1262
  ForegroundServiceConfig,
1063
1263
  NotificationChannelConfig,
1264
+ EventLogQueryOptions,
1265
+ EventLogEntry,
1064
1266
  } from "expo-beacon";
1065
1267
  ```
1066
1268
 
@@ -1089,6 +1291,8 @@ type PairedBeacon = {
1089
1291
  uuid: string;
1090
1292
  major: number;
1091
1293
  minor: number;
1294
+ name?: string; // Optional BLE device name
1295
+ timeoutSeconds?: number; // Fires onBeaconTimeout after this duration in range
1092
1296
  };
1093
1297
  ```
1094
1298
 
@@ -1146,6 +1350,8 @@ type PairedEddystone = {
1146
1350
  identifier: string;
1147
1351
  namespace: string; // 20 hex chars
1148
1352
  instance: string; // 12 hex chars
1353
+ name?: string; // Optional BLE device name
1354
+ timeoutSeconds?: number; // Fires onEddystoneTimeout after this duration in range
1149
1355
  };
1150
1356
  ```
1151
1357
 
@@ -1234,6 +1440,59 @@ type NotificationChannelConfig = {
1234
1440
  };
1235
1441
  ```
1236
1442
 
1443
+ ### `BeaconTimeoutEvent`
1444
+
1445
+ Payload for `onBeaconTimeout`.
1446
+
1447
+ ```ts
1448
+ type BeaconTimeoutEvent = {
1449
+ identifier: string;
1450
+ uuid: string;
1451
+ major: number;
1452
+ minor: number;
1453
+ distance: number; // Metres at timeout fire (–1 if unavailable)
1454
+ };
1455
+ ```
1456
+
1457
+ ### `EddystoneTimeoutEvent`
1458
+
1459
+ Payload for `onEddystoneTimeout`.
1460
+
1461
+ ```ts
1462
+ type EddystoneTimeoutEvent = {
1463
+ identifier: string;
1464
+ namespace: string;
1465
+ instance: string;
1466
+ distance: number; // Metres at timeout fire (–1 if unavailable)
1467
+ };
1468
+ ```
1469
+
1470
+ ### `EventLogQueryOptions`
1471
+
1472
+ Passed to `getEventLogs()`.
1473
+
1474
+ ```ts
1475
+ type EventLogQueryOptions = {
1476
+ limit?: number; // Max entries (default: 1000, max: 10000)
1477
+ eventType?: string; // Filter by event name
1478
+ sinceTimestamp?: number; // Only events after this ms-epoch timestamp
1479
+ };
1480
+ ```
1481
+
1482
+ ### `EventLogEntry`
1483
+
1484
+ Returned by `getEventLogs()`.
1485
+
1486
+ ```ts
1487
+ type EventLogEntry = {
1488
+ id: number; // Auto-increment row ID
1489
+ timestamp: number; // Milliseconds since epoch
1490
+ eventType: string; // e.g. "onBeaconEnter"
1491
+ identifier?: string; // Beacon identifier, if available
1492
+ data: Record<string, unknown>; // Full event payload
1493
+ };
1494
+ ```
1495
+
1237
1496
  ---
1238
1497
 
1239
1498
  ## Background Behaviour
@@ -0,0 +1,98 @@
1
+ package expo.modules.beacon
2
+
3
+ import android.content.ContentValues
4
+ import android.content.Context
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import android.database.sqlite.SQLiteOpenHelper
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * SQLite-backed event logger for beacon events.
11
+ * Thread-safe — all writes go through a single SQLiteOpenHelper.
12
+ */
13
+ internal class BeaconEventLogger(context: Context) :
14
+ SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
15
+
16
+ companion object {
17
+ private const val DB_NAME = "expo_beacon_events.db"
18
+ private const val DB_VERSION = 1
19
+ private const val TABLE = "events"
20
+ }
21
+
22
+ override fun onCreate(db: SQLiteDatabase) {
23
+ db.execSQL("""
24
+ CREATE TABLE IF NOT EXISTS $TABLE (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ timestamp INTEGER NOT NULL,
27
+ event_type TEXT NOT NULL,
28
+ identifier TEXT,
29
+ data TEXT NOT NULL
30
+ )
31
+ """.trimIndent())
32
+ db.execSQL("CREATE INDEX IF NOT EXISTS idx_events_ts ON $TABLE (timestamp)")
33
+ db.execSQL("CREATE INDEX IF NOT EXISTS idx_events_type ON $TABLE (event_type)")
34
+ }
35
+
36
+ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
37
+ db.execSQL("DROP TABLE IF EXISTS $TABLE")
38
+ onCreate(db)
39
+ }
40
+
41
+ fun logEvent(eventType: String, identifier: String?, data: Map<String, Any?>) {
42
+ val json = JSONObject()
43
+ data.forEach { (k, v) -> json.put(k, v ?: JSONObject.NULL) }
44
+ val values = ContentValues().apply {
45
+ put("timestamp", System.currentTimeMillis())
46
+ put("event_type", eventType)
47
+ put("identifier", identifier)
48
+ put("data", json.toString())
49
+ }
50
+ writableDatabase.insert(TABLE, null, values)
51
+ }
52
+
53
+ fun getEvents(limit: Int = 1000, eventType: String? = null, sinceTimestamp: Long? = null): List<Map<String, Any?>> {
54
+ val args = mutableListOf<String>()
55
+ val clauses = mutableListOf<String>()
56
+ if (eventType != null) {
57
+ clauses.add("event_type = ?")
58
+ args.add(eventType)
59
+ }
60
+ if (sinceTimestamp != null) {
61
+ clauses.add("timestamp >= ?")
62
+ args.add(sinceTimestamp.toString())
63
+ }
64
+ val where = if (clauses.isEmpty()) "" else "WHERE ${clauses.joinToString(" AND ")}"
65
+ val safeLimit = limit.coerceIn(1, 10000)
66
+ val cursor = readableDatabase.rawQuery(
67
+ "SELECT id, timestamp, event_type, identifier, data FROM $TABLE $where ORDER BY timestamp DESC LIMIT ?",
68
+ (args + safeLimit.toString()).toTypedArray()
69
+ )
70
+ val results = mutableListOf<Map<String, Any?>>()
71
+ cursor.use {
72
+ while (it.moveToNext()) {
73
+ results.add(buildMap {
74
+ put("id", it.getLong(0))
75
+ put("timestamp", it.getLong(1))
76
+ put("eventType", it.getString(2))
77
+ put("identifier", it.getString(3))
78
+ put("data", try {
79
+ val json = JSONObject(it.getString(4))
80
+ val map = mutableMapOf<String, Any?>()
81
+ json.keys().forEach { k -> map[k] = json.opt(k) }
82
+ map
83
+ } catch (_: Exception) { emptyMap<String, Any?>() })
84
+ })
85
+ }
86
+ }
87
+ return results
88
+ }
89
+
90
+ fun clearEvents() {
91
+ writableDatabase.delete(TABLE, null, null)
92
+ }
93
+
94
+ fun destroyDatabase(context: Context) {
95
+ close()
96
+ context.deleteDatabase(DB_NAME)
97
+ }
98
+ }
@@ -41,6 +41,7 @@ class BeaconEventReceiver(
41
41
  "enter" -> "onEddystoneEnter"
42
42
  "exit" -> "onEddystoneExit"
43
43
  "distance" -> "onEddystoneDistance"
44
+ "timeout" -> "onEddystoneTimeout"
44
45
  else -> return
45
46
  }
46
47
  onEvent(eventName, params)
@@ -62,6 +63,7 @@ class BeaconEventReceiver(
62
63
  "enter" -> "onBeaconEnter"
63
64
  "exit" -> "onBeaconExit"
64
65
  "distance" -> "onBeaconDistance"
66
+ "timeout" -> "onBeaconTimeout"
65
67
  else -> return
66
68
  }
67
69
  onEvent(eventName, params)
@@ -6,7 +6,9 @@ import android.content.Intent
6
6
  import android.content.SharedPreferences
7
7
  import android.content.pm.ServiceInfo
8
8
  import android.os.Build
9
+ import android.os.Handler
9
10
  import android.os.IBinder
11
+ import android.os.Looper
10
12
  import android.os.RemoteException
11
13
  import android.util.Log
12
14
  import java.util.concurrent.atomic.AtomicInteger
@@ -48,6 +50,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
48
50
  // Distance logging
49
51
  private val distanceLogRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
50
52
 
53
+ // Timeout timers — fire once after beacon stays in range for configured duration
54
+ private val timeoutHandler = Handler(Looper.getMainLooper())
55
+ private val timeoutRunnables = java.util.concurrent.ConcurrentHashMap<String, Runnable>()
56
+ // Per-beacon timeout seconds lookup (identifier → seconds), loaded from paired data
57
+ private val beaconTimeouts = java.util.concurrent.ConcurrentHashMap<String, Int>()
58
+
51
59
  companion object {
52
60
  private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
53
61
 
@@ -123,6 +131,25 @@ class BeaconForegroundService : Service(), BeaconConsumer {
123
131
  val eddystoneJson = eddystonePrefs.getString(EDDYSTONE_PREFS_KEY, "[]") ?: "[]"
124
132
  val eddystones = try { JSONArray(eddystoneJson) } catch (_: Exception) { JSONArray() }
125
133
 
134
+ // Build timeout lookup from paired beacon data
135
+ beaconTimeouts.clear()
136
+ for (i in 0 until beacons.length()) {
137
+ val b = beacons.getJSONObject(i)
138
+ val id = b.getString("identifier")
139
+ if (b.has("timeoutSeconds")) {
140
+ val secs = b.optInt("timeoutSeconds", 0)
141
+ if (secs > 0) beaconTimeouts[id] = secs
142
+ }
143
+ }
144
+ for (i in 0 until eddystones.length()) {
145
+ val e = eddystones.getJSONObject(i)
146
+ val id = e.getString("identifier")
147
+ if (e.has("timeoutSeconds")) {
148
+ val secs = e.optInt("timeoutSeconds", 0)
149
+ if (secs > 0) beaconTimeouts[id] = secs
150
+ }
151
+ }
152
+
126
153
  // Stop previous regions and distance-log ranging
127
154
  distanceLogRegions.forEach {
128
155
  try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
@@ -212,6 +239,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
212
239
  enteredRegions.add(region.uniqueId)
213
240
  sendBeaconBroadcast(region, "enter", -1.0)
214
241
  showEnterExitNotification(region, "enter")
242
+ scheduleTimeoutIfConfigured(region)
215
243
  }
216
244
  }
217
245
 
@@ -229,12 +257,14 @@ class BeaconForegroundService : Service(), BeaconConsumer {
229
257
  missCounters.remove(region.uniqueId)
230
258
  }
231
259
  if (wasEntered) {
260
+ cancelTimeout(region.uniqueId)
232
261
  sendBeaconBroadcast(region, "exit", -1.0)
233
262
  showEnterExitNotification(region, "exit")
234
263
  }
235
264
  return
236
265
  }
237
266
 
267
+ cancelTimeout(region.uniqueId)
238
268
  enteredRegions.remove(region.uniqueId)
239
269
  sendBeaconBroadcast(region, "exit", -1.0)
240
270
  showEnterExitNotification(region, "exit")
@@ -268,8 +298,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
268
298
  rangingRegions.remove(region)
269
299
  sendBeaconBroadcast(region, "enter", beacon.distance)
270
300
  showEnterExitNotification(region, "enter")
301
+ scheduleTimeoutIfConfigured(region)
271
302
  }
272
303
  HysteresisAction.EXIT -> {
304
+ cancelTimeout(region.uniqueId)
273
305
  enteredRegions.remove(region.uniqueId)
274
306
  rangingRegions.add(region)
275
307
  sendBeaconBroadcast(region, "exit", beacon.distance)
@@ -283,6 +315,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
283
315
  missCounters[region.uniqueId] = count
284
316
 
285
317
  if (enteredRegions.contains(region.uniqueId) && count >= EXIT_MISS_THRESHOLD) {
318
+ cancelTimeout(region.uniqueId)
286
319
  enteredRegions.remove(region.uniqueId)
287
320
  missCounters[region.uniqueId] = 0
288
321
  enterCounters[region.uniqueId] = 0
@@ -344,6 +377,27 @@ class BeaconForegroundService : Service(), BeaconConsumer {
344
377
  return HysteresisAction.NONE
345
378
  }
346
379
 
380
+ // MARK: - Timeout timer helpers
381
+
382
+ private fun scheduleTimeoutIfConfigured(region: Region) {
383
+ val seconds = beaconTimeouts[region.uniqueId] ?: return
384
+ // Cancel any existing timer (shouldn't happen, but be safe)
385
+ cancelTimeout(region.uniqueId)
386
+ val runnable = Runnable {
387
+ timeoutRunnables.remove(region.uniqueId)
388
+ // Only fire if the beacon is still in range
389
+ if (enteredRegions.contains(region.uniqueId)) {
390
+ sendBeaconBroadcast(region, "timeout", -1.0)
391
+ }
392
+ }
393
+ timeoutRunnables[region.uniqueId] = runnable
394
+ timeoutHandler.postDelayed(runnable, seconds * 1000L)
395
+ }
396
+
397
+ private fun cancelTimeout(regionId: String) {
398
+ timeoutRunnables.remove(regionId)?.let { timeoutHandler.removeCallbacks(it) }
399
+ }
400
+
347
401
  private fun sendBeaconBroadcast(region: Region, eventType: String, distance: Double) {
348
402
  // Determine if this is an Eddystone region based on identifier format
349
403
  // Eddystone regions have id1 as a hex namespace (not a UUID)
@@ -470,6 +524,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
470
524
  }
471
525
 
472
526
  override fun onDestroy() {
527
+ timeoutHandler.removeCallbacksAndMessages(null)
528
+ timeoutRunnables.clear()
529
+ beaconTimeouts.clear()
473
530
  beaconManager.removeMonitorNotifier(monitorNotifier)
474
531
  beaconManager.removeRangeNotifier(rangeNotifier)
475
532
  beaconManager.removeRangeNotifier(distanceLoggingRangeNotifier)