expo-beacon 0.6.3 → 0.6.6

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.
@@ -22,6 +22,9 @@
22
22
  <!-- Receive boot broadcast to restart monitoring after device reboot -->
23
23
  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
24
24
 
25
+ <!-- Battery optimization exemption for reliable background BLE scanning -->
26
+ <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
27
+
25
28
  <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
26
29
 
27
30
  <application>
@@ -30,9 +33,10 @@
30
33
  android:name="org.altbeacon.beacon.service.BeaconService"
31
34
  android:enabled="true"
32
35
  android:exported="false"
36
+ android:foregroundServiceType="connectedDevice"
33
37
  android:isolatedProcess="false"
34
38
  android:label="iBeacon Service"
35
- tools:replace="android:label" />
39
+ tools:replace="android:label,android:foregroundServiceType" />
36
40
 
37
41
  <service
38
42
  android:name="org.altbeacon.beacon.BeaconIntentProcessor"
@@ -1,8 +1,10 @@
1
1
  package expo.modules.beacon
2
2
 
3
3
  // Shared constants used across ExpoBeaconModule and BeaconForegroundService.
4
- // IMPORTANT: Timing constants (EXIT_MISS_THRESHOLD, HYSTERESIS_COUNT) must stay
5
- // in sync with ExpoBeaconModule.swift (iOS).
4
+ // NOTE: EXIT_MISS_THRESHOLD and scan timing deviate from iOS intentionally —
5
+ // Android requires a non-zero between-scan gap to prevent OS-level BLE
6
+ // throttling, and a higher miss threshold to tolerate brief scan pauses.
7
+ // HYSTERESIS_COUNT should stay in sync with ExpoBeaconModule.swift (iOS).
6
8
 
7
9
  internal const val PREFS_NAME = "expo.beacon.paired"
8
10
  internal const val PREFS_KEY = "paired_beacons"
@@ -13,12 +15,38 @@ internal const val MONITORING_OPTIONS_PREFS = "expo.beacon.monitoring_options"
13
15
  internal const val EVENT_LOGGING_PREFS = "expo.beacon.event_logging"
14
16
  internal const val EVENT_LOGGING_ENABLED_KEY = "enabled"
15
17
 
16
- /** Number of consecutive ranging misses before emitting a distance-based exit event. */
17
- internal const val EXIT_MISS_THRESHOLD = 3
18
+ /** Foreground-service scan window for background monitoring responsiveness. */
19
+ internal const val MONITORING_SCAN_PERIOD_MS = 1100L
20
+
21
+ /**
22
+ * Gap between scan windows while the foreground service is active.
23
+ * A non-zero gap prevents Android from throttling continuous BLE scans
24
+ * after a few minutes — the BLE stack needs brief idle periods to avoid
25
+ * OS-level scan suppression on many devices (Samsung, Pixel, etc.).
26
+ */
27
+ internal const val MONITORING_BETWEEN_SCAN_PERIOD_MS = 1000L
28
+
29
+ /** Ignore monitor-based exits if ranging saw the beacon within this window. */
30
+ internal const val RECENT_RANGING_SIGHTING_GRACE_MS = 25000L
31
+
32
+ /**
33
+ * Number of consecutive ranging misses before emitting a distance-based exit event.
34
+ * With a ~2.1 s scan cycle (1100 ms scan + 1000 ms gap), 10 misses ≈ 21 s of
35
+ * silence before declaring exit — tolerant of brief BLE gaps while still
36
+ * responsive to actual departures.
37
+ */
38
+ internal const val EXIT_MISS_THRESHOLD = 10
18
39
 
19
40
  /** Number of consecutive readings required to confirm a distance-based enter or exit transition. */
20
41
  internal const val HYSTERESIS_COUNT = 3
21
42
 
43
+ /**
44
+ * AltBeacon region exit period — how long after the last sighting before
45
+ * MonitorNotifier.didExitRegion fires. Set generously to avoid premature
46
+ * monitor-level exits that bypass ranging hysteresis.
47
+ */
48
+ internal const val REGION_EXIT_PERIOD_MS = 60000L
49
+
22
50
  /** Shared log tag for the expo-beacon module. */
23
51
  internal const val TAG = "ExpoBeacon"
24
52
 
@@ -10,6 +10,7 @@ import android.os.Handler
10
10
  import android.os.IBinder
11
11
  import android.os.Looper
12
12
  import android.os.RemoteException
13
+ import android.os.SystemClock
13
14
  import android.util.Log
14
15
  import java.util.concurrent.atomic.AtomicInteger
15
16
  import androidx.core.app.NotificationCompat
@@ -18,7 +19,7 @@ import org.altbeacon.beacon.*
18
19
  import org.json.JSONArray
19
20
 
20
21
  private const val CHANNEL_ID = "expo_beacon_channel"
21
- private const val FOREGROUND_NOTIF_ID = 1001
22
+ internal const val FOREGROUND_NOTIF_ID = 1001
22
23
  /**
23
24
  * Base ID for per-beacon enter/exit notifications; incremented per unique region.
24
25
  * With FOREGROUND_NOTIF_ID at 1001, this allows up to 999 unique regions
@@ -31,11 +32,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
31
32
  private lateinit var beaconManager: BeaconManager
32
33
  private val monitoredRegions = mutableListOf<Region>()
33
34
 
35
+ /** Tracks whether onBeaconServiceConnect has fired for the current bind. */
36
+ @Volatile private var serviceConnected = false
37
+
34
38
  // Distance filtering
35
39
  @Volatile private var maxDistance: Double? = null
36
40
  @Volatile private var exitDistance: Double? = null
37
- private val rangingRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
41
+ private val monitoredRegionIds = java.util.concurrent.CopyOnWriteArraySet<String>()
38
42
  private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
43
+ private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap<String, Long>()
39
44
 
40
45
  // Hysteresis counters (synchronized on distanceLock)
41
46
  private val distanceLock = Any()
@@ -57,42 +62,31 @@ class BeaconForegroundService : Service(), BeaconConsumer {
57
62
  private val beaconTimeouts = java.util.concurrent.ConcurrentHashMap<String, Int>()
58
63
  private var eventLogger: BeaconEventLogger? = null
59
64
 
60
- companion object {
61
- private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
62
-
63
- fun start(context: Context) {
64
- context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
65
- .edit().putBoolean("active", true).apply()
66
- val intent = Intent(context, BeaconForegroundService::class.java)
67
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
68
- context.startForegroundService(intent)
69
- } else {
70
- context.startService(intent)
71
- }
72
- }
73
-
74
- fun stop(context: Context) {
75
- context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
76
- .edit().putBoolean("active", false).apply()
77
- context.stopService(Intent(context, BeaconForegroundService::class.java))
78
- }
79
-
80
- fun isMonitoringActive(context: Context): Boolean {
81
- return context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
82
- .getBoolean("active", false)
83
- }
84
- }
85
-
86
65
  override fun onCreate() {
87
66
  super.onCreate()
88
67
  createNotificationChannel()
89
68
  beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
90
69
  BeaconParsers.ensureRegistered(manager)
91
70
  try { manager.setEnableScheduledScanJobs(false) } catch (e: IllegalStateException) { Log.w(TAG, "setEnableScheduledScanJobs failed", e) }
92
- manager.setBackgroundBetweenScanPeriod(5000L) // 5s between scans
93
- manager.setBackgroundScanPeriod(1100L) // 1.1s scan window
94
- manager.setForegroundScanPeriod(1000L) // 1s scan window for distance logging
95
- manager.setForegroundBetweenScanPeriod(0L) // no pause between scans
71
+ manager.setBackgroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
72
+ manager.setBackgroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
73
+ manager.setForegroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
74
+ manager.setForegroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
75
+ }
76
+ // Increase AltBeacon's region exit period so didExitRegion doesn't fire
77
+ // prematurely during brief BLE scan gaps.
78
+ BeaconManager.setRegionExitPeriod(REGION_EXIT_PERIOD_MS)
79
+ // Let AltBeacon's internal BeaconService run as a foreground service so
80
+ // Samsung/OEM battery enforcement doesn't kill BLE scanning after ~4 min.
81
+ // Reset first to clear any stale state from a previous session.
82
+ try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
83
+ try {
84
+ beaconManager.enableForegroundServiceScanning(
85
+ buildForegroundNotification(), FOREGROUND_NOTIF_ID
86
+ )
87
+ } catch (e: IllegalStateException) {
88
+ // Already bound by another consumer (e.g. a scan in progress) — non-fatal.
89
+ Log.w(TAG, "enableForegroundServiceScanning skipped (already bound)", e)
96
90
  }
97
91
  }
98
92
 
@@ -106,11 +100,18 @@ class BeaconForegroundService : Service(), BeaconConsumer {
106
100
  } else {
107
101
  startForeground(FOREGROUND_NOTIF_ID, buildForegroundNotification())
108
102
  }
109
- beaconManager.bind(this)
103
+ if (serviceConnected) {
104
+ // Already bound from a prior onStartCommand — reload regions directly
105
+ // so that re-starting monitoring from JS always takes effect.
106
+ loadAndMonitorRegions()
107
+ } else {
108
+ beaconManager.bind(this)
109
+ }
110
110
  return START_STICKY
111
111
  }
112
112
 
113
113
  override fun onBeaconServiceConnect() {
114
+ serviceConnected = true
114
115
  // Read max distance and exit distance from options prefs
115
116
  val optPrefs = getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
116
117
  maxDistance = optPrefs.getString("max_distance", null)?.toDoubleOrNull()
@@ -156,6 +157,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
156
157
  try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
157
158
  }
158
159
  distanceLogRegions.clear()
160
+ monitoredRegionIds.clear()
161
+ enteredRegions.clear()
162
+ lastSeenAtMs.clear()
163
+ timeoutHandler.removeCallbacksAndMessages(null)
164
+ timeoutRunnables.clear()
165
+ synchronized(distanceLock) {
166
+ enterCounters.clear()
167
+ exitCounters.clear()
168
+ missCounters.clear()
169
+ }
159
170
  monitoredRegions.forEach {
160
171
  try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
161
172
  }
@@ -171,6 +182,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
171
182
  Identifier.fromInt(b.getInt("minor"))
172
183
  )
173
184
  monitoredRegions.add(region)
185
+ monitoredRegionIds.add(region.uniqueId)
174
186
  try {
175
187
  beaconManager.startMonitoringBeaconsInRegion(region)
176
188
  } catch (e: RemoteException) {
@@ -200,6 +212,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
200
212
  null
201
213
  )
202
214
  monitoredRegions.add(region)
215
+ monitoredRegionIds.add(region.uniqueId)
203
216
  try {
204
217
  beaconManager.startMonitoringBeaconsInRegion(region)
205
218
  } catch (ex: RemoteException) {
@@ -224,51 +237,42 @@ class BeaconForegroundService : Service(), BeaconConsumer {
224
237
 
225
238
  // Distance logging only — emits distance broadcasts. Enter/exit logic lives in rangeNotifier.
226
239
  private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
240
+ if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
227
241
  val closest = beacons.filter { it.distance >= 0 }.minByOrNull { it.distance }
228
242
  if (closest != null) {
243
+ lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
229
244
  sendBeaconBroadcast(region, "distance", closest.distance)
230
245
  }
231
246
  }
232
247
 
233
248
  private val monitorNotifier = object : MonitorNotifier {
234
249
  override fun didEnterRegion(region: Region) {
235
- val maxDist = maxDistance
236
- if (maxDist != null) {
237
- // Mark region for distance confirmation — ranging is already active via distance logging
238
- rangingRegions.add(region)
239
- } else {
240
- enteredRegions.add(region.uniqueId)
241
- sendBeaconBroadcast(region, "enter", -1.0)
242
- showEnterExitNotification(region, "enter")
243
- scheduleTimeoutIfConfigured(region)
244
- }
250
+ // Enter is synthesized from ranging so distance and enter/exit stay in sync.
245
251
  }
246
252
 
247
253
  override fun didExitRegion(region: Region) {
248
- rangingRegions.remove(region)
249
-
250
- if (maxDistance != null) {
251
- // Distance ranging normally handles exit. But if the beacon was
252
- // in "entered" state when OS fires didExitRegion, we must emit
253
- // the exit event — ranging will no longer receive readings.
254
- val wasEntered = enteredRegions.remove(region.uniqueId)
255
- synchronized(distanceLock) {
256
- enterCounters.remove(region.uniqueId)
257
- exitCounters.remove(region.uniqueId)
258
- missCounters.remove(region.uniqueId)
259
- }
260
- if (wasEntered) {
261
- cancelTimeout(region.uniqueId)
262
- sendBeaconBroadcast(region, "exit", -1.0)
263
- showEnterExitNotification(region, "exit")
264
- }
254
+ if (!monitoredRegionIds.contains(region.uniqueId)) return
255
+ if (wasSeenRecently(region.uniqueId)) {
256
+ Log.d(TAG, "Ignoring stale didExitRegion for ${region.uniqueId}; beacon was seen by ranging recently")
265
257
  return
266
258
  }
267
259
 
268
- cancelTimeout(region.uniqueId)
269
- enteredRegions.remove(region.uniqueId)
270
- sendBeaconBroadcast(region, "exit", -1.0)
271
- showEnterExitNotification(region, "exit")
260
+ lastSeenAtMs.remove(region.uniqueId)
261
+
262
+ // Ranging-based hysteresis handles exit in the normal case. If the OS
263
+ // fires didExitRegion after ranging has already stopped, emit exit as a
264
+ // safety net only if the region was previously in the entered state.
265
+ val wasEntered = enteredRegions.remove(region.uniqueId)
266
+ synchronized(distanceLock) {
267
+ enterCounters.remove(region.uniqueId)
268
+ exitCounters.remove(region.uniqueId)
269
+ missCounters.remove(region.uniqueId)
270
+ }
271
+ if (wasEntered) {
272
+ cancelTimeout(region.uniqueId)
273
+ sendBeaconBroadcast(region, "exit", -1.0)
274
+ showEnterExitNotification(region, "exit")
275
+ }
272
276
  }
273
277
 
274
278
  override fun didDetermineStateForRegion(state: Int, region: Region) {
@@ -277,11 +281,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
277
281
  }
278
282
 
279
283
  // Single source of truth for distance-based enter/exit with hysteresis.
280
- // Processes regions added to rangingRegions by monitorNotifier.didEnterRegion,
281
- // and also handles exit via miss counting when beacons disappear.
284
+ // Processes only actual monitoring regions and handles exit via miss counting
285
+ // when beacons disappear.
282
286
  private val rangeNotifier = RangeNotifier { beacons, region ->
283
- val maxDist = maxDistance ?: return@RangeNotifier
284
- if (!rangingRegions.contains(region) && !enteredRegions.contains(region.uniqueId)) return@RangeNotifier
287
+ val maxDist = maxDistance
288
+ if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
285
289
 
286
290
  val beacon = beacons
287
291
  .filter { it.distance >= 0 }
@@ -290,13 +294,13 @@ class BeaconForegroundService : Service(), BeaconConsumer {
290
294
  synchronized(distanceLock) {
291
295
  if (beacon != null) {
292
296
  // Got a valid reading — reset miss counter
297
+ lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
293
298
  missCounters[region.uniqueId] = 0
294
299
 
295
300
  val action = evaluateDistanceHysteresis(region.uniqueId, beacon.distance, maxDist)
296
301
  when (action) {
297
302
  HysteresisAction.ENTER -> {
298
303
  enteredRegions.add(region.uniqueId)
299
- rangingRegions.remove(region)
300
304
  sendBeaconBroadcast(region, "enter", beacon.distance)
301
305
  showEnterExitNotification(region, "enter")
302
306
  scheduleTimeoutIfConfigured(region)
@@ -304,14 +308,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
304
308
  HysteresisAction.EXIT -> {
305
309
  cancelTimeout(region.uniqueId)
306
310
  enteredRegions.remove(region.uniqueId)
307
- rangingRegions.add(region)
308
311
  sendBeaconBroadcast(region, "exit", beacon.distance)
309
312
  showEnterExitNotification(region, "exit")
310
313
  }
311
314
  HysteresisAction.NONE -> {}
312
315
  }
313
316
  } else {
314
- // No valid beacon reading — track consecutive misses for exit detection
317
+ // No valid beacon reading — break distance hysteresis streaks and
318
+ // track consecutive misses for disappearance-based exit detection.
319
+ enterCounters[region.uniqueId] = 0
320
+ exitCounters[region.uniqueId] = 0
315
321
  val count = (missCounters[region.uniqueId] ?: 0) + 1
316
322
  missCounters[region.uniqueId] = count
317
323
 
@@ -349,8 +355,23 @@ class BeaconForegroundService : Service(), BeaconConsumer {
349
355
  private fun evaluateDistanceHysteresis(
350
356
  regionId: String,
351
357
  distance: Double,
352
- maxDist: Double
358
+ maxDist: Double?
353
359
  ): HysteresisAction {
360
+ if (maxDist == null) {
361
+ exitCounters[regionId] = 0
362
+ if (enteredRegions.contains(regionId)) {
363
+ enterCounters[regionId] = 0
364
+ return HysteresisAction.NONE
365
+ }
366
+ val count = (enterCounters[regionId] ?: 0) + 1
367
+ enterCounters[regionId] = count
368
+ if (count >= HYSTERESIS_COUNT) {
369
+ enterCounters[regionId] = 0
370
+ return HysteresisAction.ENTER
371
+ }
372
+ return HysteresisAction.NONE
373
+ }
374
+
354
375
  val exitDist = effectiveExitDistance(maxDist)
355
376
  if (distance <= maxDist) {
356
377
  // Inside enter threshold
@@ -378,6 +399,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
378
399
  return HysteresisAction.NONE
379
400
  }
380
401
 
402
+ private fun wasSeenRecently(regionId: String): Boolean {
403
+ val lastSeen = lastSeenAtMs[regionId] ?: return false
404
+ return SystemClock.elapsedRealtime() - lastSeen <= RECENT_RANGING_SIGHTING_GRACE_MS
405
+ }
406
+
381
407
  // MARK: - Timeout timer helpers
382
408
 
383
409
  private fun scheduleTimeoutIfConfigured(region: Region) {
@@ -546,26 +572,94 @@ class BeaconForegroundService : Service(), BeaconConsumer {
546
572
  }
547
573
 
548
574
  private fun buildForegroundNotification(): Notification {
549
- val config = readNotificationConfig()
550
- val fgConfig = config.optJSONObject("foregroundService")
575
+ return Companion.buildForegroundNotification(this)
576
+ }
551
577
 
552
- val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() }
553
- ?: "Beacon Monitoring Active"
554
- val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() }
555
- ?: "Monitoring for iBeacons in the background"
556
- val iconName = fgConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
557
- val iconResId = iconName?.let { name ->
558
- try { resources.getIdentifier(name, "drawable", packageName).takeIf { it != 0 } }
559
- catch (_: Exception) { null }
560
- } ?: android.R.drawable.ic_dialog_info
578
+ companion object {
579
+ private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
561
580
 
562
- return NotificationCompat.Builder(this, CHANNEL_ID)
563
- .setSmallIcon(iconResId)
564
- .setContentTitle(title)
565
- .setContentText(text)
566
- .setPriority(NotificationCompat.PRIORITY_LOW)
567
- .setOngoing(true)
568
- .build()
581
+ fun start(context: Context) {
582
+ context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
583
+ .edit().putBoolean("active", true).apply()
584
+ val intent = Intent(context, BeaconForegroundService::class.java)
585
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
586
+ context.startForegroundService(intent)
587
+ } else {
588
+ context.startService(intent)
589
+ }
590
+ }
591
+
592
+ fun stop(context: Context) {
593
+ context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
594
+ .edit().putBoolean("active", false).apply()
595
+ context.stopService(Intent(context, BeaconForegroundService::class.java))
596
+ }
597
+
598
+ fun isMonitoringActive(context: Context): Boolean {
599
+ return context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
600
+ .getBoolean("active", false)
601
+ }
602
+
603
+ /**
604
+ * Ensure the notification channel exists. Must be called before building
605
+ * a notification from a non-service context (e.g. ExpoBeaconModule).
606
+ */
607
+ fun ensureNotificationChannel(context: Context) {
608
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
609
+ val json = context.getSharedPreferences(NOTIFICATION_CONFIG_PREFS, Context.MODE_PRIVATE)
610
+ .getString("config", null)
611
+ val config = try { org.json.JSONObject(json ?: "") } catch (_: Exception) { org.json.JSONObject() }
612
+ val channelConfig = config.optJSONObject("channel")
613
+
614
+ val channelName = channelConfig?.optString("name")?.takeIf { it.isNotEmpty() }
615
+ ?: "Beacon Monitoring"
616
+ val channelDesc = channelConfig?.optString("description")?.takeIf { it.isNotEmpty() }
617
+ ?: "Used for background iBeacon region monitoring"
618
+ val importance = when (channelConfig?.optString("importance")) {
619
+ "high" -> NotificationManager.IMPORTANCE_HIGH
620
+ "default" -> NotificationManager.IMPORTANCE_DEFAULT
621
+ else -> NotificationManager.IMPORTANCE_LOW
622
+ }
623
+
624
+ val notifMgr = context.getSystemService(NotificationManager::class.java)
625
+ if (notifMgr?.getNotificationChannel(CHANNEL_ID) == null) {
626
+ val channel = NotificationChannel(CHANNEL_ID, channelName, importance).apply {
627
+ description = channelDesc
628
+ }
629
+ notifMgr?.createNotificationChannel(channel)
630
+ }
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Build the foreground notification from any Context (service or module).
636
+ * Shared so that ExpoBeaconModule can pass the same notification to
637
+ * enableForegroundServiceScanning() before the service starts.
638
+ */
639
+ fun buildForegroundNotification(context: Context): Notification {
640
+ val json = context.getSharedPreferences(NOTIFICATION_CONFIG_PREFS, Context.MODE_PRIVATE)
641
+ .getString("config", null)
642
+ val config = try { org.json.JSONObject(json ?: "") } catch (_: Exception) { org.json.JSONObject() }
643
+ val fgConfig = config.optJSONObject("foregroundService")
644
+
645
+ val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() }
646
+ ?: "Beacon Monitoring Active"
647
+ val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() }
648
+ ?: "Monitoring for iBeacons in the background"
649
+ val iconName = fgConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
650
+ val iconResId = iconName?.let { name ->
651
+ try { context.resources.getIdentifier(name, "drawable", context.packageName).takeIf { it != 0 } }
652
+ catch (_: Exception) { null }
653
+ } ?: android.R.drawable.ic_dialog_info
654
+
655
+ return NotificationCompat.Builder(context, CHANNEL_ID)
656
+ .setSmallIcon(iconResId)
657
+ .setContentTitle(title)
658
+ .setContentText(text)
659
+ .setPriority(NotificationCompat.PRIORITY_LOW)
660
+ .setOngoing(true)
661
+ .build()
662
+ }
569
663
  }
570
664
 
571
665
  private fun readNotificationConfig(): org.json.JSONObject {
@@ -575,17 +669,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
575
669
  }
576
670
 
577
671
  override fun onDestroy() {
672
+ serviceConnected = false
578
673
  timeoutHandler.removeCallbacksAndMessages(null)
579
674
  timeoutRunnables.clear()
580
675
  beaconTimeouts.clear()
676
+ lastSeenAtMs.clear()
677
+ monitoredRegionIds.clear()
581
678
  releaseEventLogger()
582
679
  beaconManager.removeMonitorNotifier(monitorNotifier)
583
680
  beaconManager.removeRangeNotifier(rangeNotifier)
584
681
  beaconManager.removeRangeNotifier(distanceLoggingRangeNotifier)
585
- rangingRegions.forEach {
586
- try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
587
- }
588
- rangingRegions.clear()
589
682
  distanceLogRegions.forEach {
590
683
  try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
591
684
  }
@@ -598,6 +691,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
598
691
  monitoredRegions.forEach {
599
692
  try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
600
693
  }
694
+ try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
601
695
  beaconManager.unbind(this)
602
696
  super.onDestroy()
603
697
  }
@@ -7,7 +7,9 @@ import android.content.IntentFilter
7
7
  import android.content.ServiceConnection
8
8
  import android.content.SharedPreferences
9
9
  import android.content.pm.PackageManager
10
+ import android.net.Uri
10
11
  import android.os.Build
12
+ import android.os.PowerManager
11
13
  import android.os.RemoteException
12
14
  import androidx.core.content.ContextCompat
13
15
  import expo.modules.interfaces.permissions.PermissionsStatus
@@ -294,6 +296,22 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
294
296
  }
295
297
  }
296
298
  }
299
+ if (maxDistance != null && (!maxDistance.isFinite() || maxDistance <= 0.0)) {
300
+ promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0", null)
301
+ return@AsyncFunction
302
+ }
303
+ if (exitDistance != null && (!exitDistance.isFinite() || exitDistance <= 0.0)) {
304
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0", null)
305
+ return@AsyncFunction
306
+ }
307
+ if (exitDistance != null && maxDistance == null) {
308
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set", null)
309
+ return@AsyncFunction
310
+ }
311
+ if (maxDistance != null && exitDistance != null && exitDistance < maxDistance) {
312
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance", null)
313
+ return@AsyncFunction
314
+ }
297
315
  ctx.getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
298
316
  .edit().apply {
299
317
  if (maxDistance != null) putString("max_distance", maxDistance.toString())
@@ -310,6 +328,21 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
310
328
  return@AsyncFunction
311
329
  }
312
330
 
331
+ // Enable AltBeacon foreground service scanning so the internal BeaconService
332
+ // runs as a foreground service, preventing Samsung/OEM BLE throttling.
333
+ // Reset first to clear stale state from a previous session / hot-reload.
334
+ if (!isBoundForScan) {
335
+ try {
336
+ BeaconForegroundService.ensureNotificationChannel(ctx)
337
+ try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
338
+ beaconManager.enableForegroundServiceScanning(
339
+ BeaconForegroundService.buildForegroundNotification(ctx), FOREGROUND_NOTIF_ID
340
+ )
341
+ } catch (_: IllegalStateException) {
342
+ // Already bound — service onCreate() will enable it instead.
343
+ }
344
+ }
345
+
313
346
  registerEventReceiver()
314
347
  BeaconForegroundService.start(ctx)
315
348
  promise.resolve(null)
@@ -327,6 +360,7 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
327
360
  return@AsyncFunction
328
361
  }
329
362
  BeaconForegroundService.stop(ctx)
363
+ try { beaconManager.disableForegroundServiceScanning() } catch (_: Exception) {}
330
364
  unregisterEventReceiver()
331
365
  promise.resolve(null)
332
366
  }
@@ -415,6 +449,43 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
415
449
  BeaconEventLogger.deleteLogDatabase(ctx)
416
450
  }
417
451
 
452
+ // MARK: - Battery Optimization
453
+
454
+ Function("isBatteryOptimizationExempt") {
455
+ val ctx = appContext.reactContext ?: return@Function false
456
+ val pm = ctx.getSystemService(Context.POWER_SERVICE) as? PowerManager
457
+ ?: return@Function false
458
+ pm.isIgnoringBatteryOptimizations(ctx.packageName)
459
+ }
460
+
461
+ AsyncFunction("requestBatteryOptimizationExemption") { promise: Promise ->
462
+ val ctx = appContext.reactContext ?: run {
463
+ promise.reject("NO_CONTEXT", "React context is not available", null)
464
+ return@AsyncFunction
465
+ }
466
+ val pm = ctx.getSystemService(Context.POWER_SERVICE) as? PowerManager
467
+ if (pm == null) {
468
+ promise.resolve(false)
469
+ return@AsyncFunction
470
+ }
471
+ if (pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
472
+ promise.resolve(true)
473
+ return@AsyncFunction
474
+ }
475
+ try {
476
+ val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
477
+ data = Uri.parse("package:${ctx.packageName}")
478
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
479
+ }
480
+ ctx.startActivity(intent)
481
+ // The system dialog is fire-and-forget; we cannot observe the result
482
+ // from a non-Activity context. Resolve true to indicate the dialog was shown.
483
+ promise.resolve(true)
484
+ } catch (e: Exception) {
485
+ promise.reject("BATTERY_OPT_ERROR", "Failed to open battery optimization settings: ${e.message}", e)
486
+ }
487
+ }
488
+
418
489
  OnDestroy {
419
490
  with(this@ExpoBeaconModule) {
420
491
  unregisterEventReceiver()
@@ -77,6 +77,18 @@ declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
77
77
  cancelScan(): void;
78
78
  /** Request Bluetooth + Location permissions. Returns true if granted. */
79
79
  requestPermissionsAsync(): Promise<boolean>;
80
+ /**
81
+ * Check whether the app is exempt from Android battery optimizations.
82
+ * Always returns true on iOS and web (no equivalent concept).
83
+ */
84
+ isBatteryOptimizationExempt(): boolean;
85
+ /**
86
+ * Request exemption from Android battery optimizations.
87
+ * Opens the system dialog asking the user to whitelist this app.
88
+ * Returns true if the dialog was shown (or already exempt), false on failure.
89
+ * Always resolves true on iOS and web.
90
+ */
91
+ requestBatteryOptimizationExemption(): Promise<boolean>;
80
92
  /** Enable SQLite event logging. All beacon events will be persisted to a local database. */
81
93
  enableEventLogging(): void;
82
94
  /** Disable event logging. Previously logged events are retained. */
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoBeaconModule.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EACL,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAE5B,OAAO,OAAO,gBAAiB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IACzE;;;;;;;;;;;OAWG;IACH,mBAAmB,CACjB,KAAK,CAAC,EAAE,MAAM,EAAE,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAE9B;;;;;OAKG;IACH,sBAAsB,CACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAEjC;;OAEG;IACH,UAAU,CACR,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEtC;;OAEG;IACH,gBAAgB,IAAI,YAAY,EAAE;IAElC;;OAEG;IACH,aAAa,CACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEzC;;OAEG;IACH,mBAAmB,IAAI,eAAe,EAAE;IAExC;;;OAGG;IACH,qBAAqB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAEvD;;;;;;;OAOG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAEpE;;OAEG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAE/B;;;OAGG;IACH,mBAAmB,IAAI,IAAI;IAE3B,iEAAiE;IACjE,kBAAkB,IAAI,IAAI;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI;IAElB,yEAAyE;IACzE,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC;IAE3C,4FAA4F;IAC5F,kBAAkB,IAAI,IAAI;IAE1B,oEAAoE;IACpE,mBAAmB,IAAI,IAAI;IAE3B;;;OAGG;IACH,YAAY,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,EAAE;IAE7D,kDAAkD;IAClD,cAAc,IAAI,IAAI;IAEtB,mEAAmE;IACnE,gBAAgB,IAAI,IAAI;CACzB;;AAED,wBAAmE"}
1
+ {"version":3,"file":"ExpoBeaconModule.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EACL,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAE5B,OAAO,OAAO,gBAAiB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IACzE;;;;;;;;;;;OAWG;IACH,mBAAmB,CACjB,KAAK,CAAC,EAAE,MAAM,EAAE,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAE9B;;;;;OAKG;IACH,sBAAsB,CACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAEjC;;OAEG;IACH,UAAU,CACR,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEtC;;OAEG;IACH,gBAAgB,IAAI,YAAY,EAAE;IAElC;;OAEG;IACH,aAAa,CACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,CAAC,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,IAAI;IAEP;;OAEG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAEzC;;OAEG;IACH,mBAAmB,IAAI,eAAe,EAAE;IAExC;;;OAGG;IACH,qBAAqB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAEvD;;;;;;;OAOG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAEpE;;OAEG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAE/B;;;OAGG;IACH,mBAAmB,IAAI,IAAI;IAE3B,iEAAiE;IACjE,kBAAkB,IAAI,IAAI;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI;IAElB,yEAAyE;IACzE,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC;IAE3C;;;OAGG;IACH,2BAA2B,IAAI,OAAO;IAEtC;;;;;OAKG;IACH,mCAAmC,IAAI,OAAO,CAAC,OAAO,CAAC;IAEvD,4FAA4F;IAC5F,kBAAkB,IAAI,IAAI;IAE1B,oEAAoE;IACpE,mBAAmB,IAAI,IAAI;IAE3B;;;OAGG;IACH,YAAY,CAAC,OAAO,CAAC,EAAE,oBAAoB,GAAG,aAAa,EAAE;IAE7D,kDAAkD;IAClD,cAAc,IAAI,IAAI;IAEtB,mEAAmE;IACnE,gBAAgB,IAAI,IAAI;CACzB;;AAED,wBAAmE"}
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoBeaconModule.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AA+IzD,eAAe,mBAAmB,CAAmB,YAAY,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\r\n\r\nimport {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n NotificationConfig,\r\n MonitoringOptions,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\ndeclare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {\r\n /**\r\n * Start a one-shot iBeacon scan. Resolves with discovered beacons after scanDuration ms.\r\n *\r\n * Pass one or more UUIDs to scan for specific beacons (uses CoreLocation on iOS).\r\n * On iOS, at least one UUID is required — Apple strips iBeacon data from BLE\r\n * advertisements, making wildcard discovery impossible. When you pass an empty\r\n * array, the module automatically uses UUIDs from paired beacons.\r\n * On Android, pass an empty array to discover all nearby iBeacons.\r\n *\r\n * @param uuids Proximity UUIDs to filter by. Empty/omitted = use paired UUIDs (iOS) or wildcard (Android).\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForBeaconsAsync(\r\n uuids?: string[],\r\n scanDuration?: number,\r\n ): Promise<BeaconScanResult[]>;\r\n\r\n /**\r\n * Start a one-shot Eddystone beacon scan using BLE.\r\n * Discovers Eddystone-UID and Eddystone-URL frames.\r\n *\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForEddystonesAsync(\r\n scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]>;\r\n\r\n /**\r\n * Register a beacon for persistent region monitoring.\r\n */\r\n pairBeacon(\r\n identifier: string,\r\n uuid: string,\r\n major: number,\r\n minor: number,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired beacon.\r\n */\r\n unpairBeacon(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired beacons.\r\n */\r\n getPairedBeacons(): PairedBeacon[];\r\n\r\n /**\r\n * Register an Eddystone-UID beacon for persistent monitoring.\r\n */\r\n pairEddystone(\r\n identifier: string,\r\n namespace: string,\r\n instance: string,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired Eddystone beacon.\r\n */\r\n unpairEddystone(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired Eddystone beacons.\r\n */\r\n getPairedEddystones(): PairedEddystone[];\r\n\r\n /**\r\n * Set persistent notification configuration. Settings are saved and applied to all\r\n * subsequent monitoring sessions until explicitly changed.\r\n */\r\n setNotificationConfig(config: NotificationConfig): void;\r\n\r\n /**\r\n * Start background region monitoring for all paired beacons.\r\n * On Android starts a foreground service.\r\n * On iOS starts CLLocationManager region monitoring.\r\n *\r\n * Accepts a plain number (backward-compatible maxDistance shorthand) or a\r\n * MonitoringOptions object with maxDistance and/or notification overrides.\r\n */\r\n startMonitoring(options?: MonitoringOptions | number): Promise<void>;\r\n\r\n /**\r\n * Stop background region monitoring.\r\n */\r\n stopMonitoring(): Promise<void>;\r\n\r\n /**\r\n * Start a continuous BLE scan. Fires `onBeaconFound` events as beacons are detected.\r\n * Call stopContinuousScan() to end the scan.\r\n */\r\n startContinuousScan(): void;\r\n\r\n /** Stop the continuous scan started by startContinuousScan(). */\r\n stopContinuousScan(): void;\r\n\r\n /**\r\n * Cancel any in-progress one-shot scan (iBeacon or Eddystone).\r\n * The pending promise will be rejected with code \"SCAN_CANCELLED\".\r\n */\r\n cancelScan(): void;\r\n\r\n /** Request Bluetooth + Location permissions. Returns true if granted. */\r\n requestPermissionsAsync(): Promise<boolean>;\r\n\r\n /** Enable SQLite event logging. All beacon events will be persisted to a local database. */\r\n enableEventLogging(): void;\r\n\r\n /** Disable event logging. Previously logged events are retained. */\r\n disableEventLogging(): void;\r\n\r\n /**\r\n * Retrieve logged beacon events from the SQLite database.\r\n * @param options Optional filters (limit, eventType, sinceTimestamp).\r\n */\r\n getEventLogs(options?: EventLogQueryOptions): EventLogEntry[];\r\n\r\n /** Delete all logged events from the database. */\r\n clearEventLogs(): void;\r\n\r\n /** Delete the entire event log database. Also disables logging. */\r\n destroyEventLogs(): void;\r\n}\r\n\r\nexport default requireNativeModule<ExpoBeaconModule>(\"ExpoBeacon\");\r\n"]}
1
+ {"version":3,"file":"ExpoBeaconModule.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AA6JzD,eAAe,mBAAmB,CAAmB,YAAY,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\r\n\r\nimport {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n NotificationConfig,\r\n MonitoringOptions,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\ndeclare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {\r\n /**\r\n * Start a one-shot iBeacon scan. Resolves with discovered beacons after scanDuration ms.\r\n *\r\n * Pass one or more UUIDs to scan for specific beacons (uses CoreLocation on iOS).\r\n * On iOS, at least one UUID is required — Apple strips iBeacon data from BLE\r\n * advertisements, making wildcard discovery impossible. When you pass an empty\r\n * array, the module automatically uses UUIDs from paired beacons.\r\n * On Android, pass an empty array to discover all nearby iBeacons.\r\n *\r\n * @param uuids Proximity UUIDs to filter by. Empty/omitted = use paired UUIDs (iOS) or wildcard (Android).\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForBeaconsAsync(\r\n uuids?: string[],\r\n scanDuration?: number,\r\n ): Promise<BeaconScanResult[]>;\r\n\r\n /**\r\n * Start a one-shot Eddystone beacon scan using BLE.\r\n * Discovers Eddystone-UID and Eddystone-URL frames.\r\n *\r\n * @param scanDuration Duration in ms (default 5000)\r\n */\r\n scanForEddystonesAsync(\r\n scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]>;\r\n\r\n /**\r\n * Register a beacon for persistent region monitoring.\r\n */\r\n pairBeacon(\r\n identifier: string,\r\n uuid: string,\r\n major: number,\r\n minor: number,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired beacon.\r\n */\r\n unpairBeacon(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired beacons.\r\n */\r\n getPairedBeacons(): PairedBeacon[];\r\n\r\n /**\r\n * Register an Eddystone-UID beacon for persistent monitoring.\r\n */\r\n pairEddystone(\r\n identifier: string,\r\n namespace: string,\r\n instance: string,\r\n name?: string,\r\n timeoutSeconds?: number,\r\n ): void;\r\n\r\n /**\r\n * Remove a previously paired Eddystone beacon.\r\n */\r\n unpairEddystone(identifier: string): void;\r\n\r\n /**\r\n * Return all currently paired Eddystone beacons.\r\n */\r\n getPairedEddystones(): PairedEddystone[];\r\n\r\n /**\r\n * Set persistent notification configuration. Settings are saved and applied to all\r\n * subsequent monitoring sessions until explicitly changed.\r\n */\r\n setNotificationConfig(config: NotificationConfig): void;\r\n\r\n /**\r\n * Start background region monitoring for all paired beacons.\r\n * On Android starts a foreground service.\r\n * On iOS starts CLLocationManager region monitoring.\r\n *\r\n * Accepts a plain number (backward-compatible maxDistance shorthand) or a\r\n * MonitoringOptions object with maxDistance and/or notification overrides.\r\n */\r\n startMonitoring(options?: MonitoringOptions | number): Promise<void>;\r\n\r\n /**\r\n * Stop background region monitoring.\r\n */\r\n stopMonitoring(): Promise<void>;\r\n\r\n /**\r\n * Start a continuous BLE scan. Fires `onBeaconFound` events as beacons are detected.\r\n * Call stopContinuousScan() to end the scan.\r\n */\r\n startContinuousScan(): void;\r\n\r\n /** Stop the continuous scan started by startContinuousScan(). */\r\n stopContinuousScan(): void;\r\n\r\n /**\r\n * Cancel any in-progress one-shot scan (iBeacon or Eddystone).\r\n * The pending promise will be rejected with code \"SCAN_CANCELLED\".\r\n */\r\n cancelScan(): void;\r\n\r\n /** Request Bluetooth + Location permissions. Returns true if granted. */\r\n requestPermissionsAsync(): Promise<boolean>;\r\n\r\n /**\r\n * Check whether the app is exempt from Android battery optimizations.\r\n * Always returns true on iOS and web (no equivalent concept).\r\n */\r\n isBatteryOptimizationExempt(): boolean;\r\n\r\n /**\r\n * Request exemption from Android battery optimizations.\r\n * Opens the system dialog asking the user to whitelist this app.\r\n * Returns true if the dialog was shown (or already exempt), false on failure.\r\n * Always resolves true on iOS and web.\r\n */\r\n requestBatteryOptimizationExemption(): Promise<boolean>;\r\n\r\n /** Enable SQLite event logging. All beacon events will be persisted to a local database. */\r\n enableEventLogging(): void;\r\n\r\n /** Disable event logging. Previously logged events are retained. */\r\n disableEventLogging(): void;\r\n\r\n /**\r\n * Retrieve logged beacon events from the SQLite database.\r\n * @param options Optional filters (limit, eventType, sinceTimestamp).\r\n */\r\n getEventLogs(options?: EventLogQueryOptions): EventLogEntry[];\r\n\r\n /** Delete all logged events from the database. */\r\n clearEventLogs(): void;\r\n\r\n /** Delete the entire event log database. Also disables logging. */\r\n destroyEventLogs(): void;\r\n}\r\n\r\nexport default requireNativeModule<ExpoBeaconModule>(\"ExpoBeacon\");\r\n"]}
@@ -20,6 +20,8 @@ declare const stub: {
20
20
  getEventLogs: (_options?: EventLogQueryOptions) => EventLogEntry[];
21
21
  clearEventLogs: () => void;
22
22
  destroyEventLogs: () => void;
23
+ isBatteryOptimizationExempt: () => boolean;
24
+ requestBatteryOptimizationExemption: () => Promise<boolean>;
23
25
  addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => {
24
26
  remove: () => void;
25
27
  };
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoBeaconModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAM5B,QAAA,MAAM,IAAI;kCAEE,MAAM,EAAE,kBACA,MAAM,KACrB,OAAO,CAAC,gBAAgB,EAAE,CAAC;6CAEZ,MAAM,KACrB,OAAO,CAAC,mBAAmB,EAAE,CAAC;8BAElB,MAAM,SACZ,MAAM,UACL,MAAM,UACN,MAAM,KACb,IAAI;gCACqB,MAAM,KAAG,IAAI;4BACnB,YAAY,EAAE;iCAErB,MAAM,cACP,MAAM,aACP,MAAM,KAChB,IAAI;mCACwB,MAAM,KAAG,IAAI;+BACnB,eAAe,EAAE;2BACrB,OAAO,CAAC,IAAI,CAAC;0BACd,OAAO,CAAC,IAAI,CAAC;+BACR,IAAI;8BACL,IAAI;sBACZ,IAAI;qCACa,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAAI;mCAClC,OAAO,CAAC,OAAO,CAAC;8BACrB,IAAI;+BACH,IAAI;8BACH,oBAAoB,KAAG,aAAa,EAAE;0BAC5C,IAAI;4BACF,IAAI;8BACA,MAAM,sBAAsB,aAAa,GAAG;;;qCAGrC,MAAM,sBAAsB;CAC9D,CAAC;AAEF,eAAe,IAAI,CAAC"}
1
+ {"version":3,"file":"ExpoBeaconModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,oBAAoB,EACpB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAM5B,QAAA,MAAM,IAAI;kCAEE,MAAM,EAAE,kBACA,MAAM,KACrB,OAAO,CAAC,gBAAgB,EAAE,CAAC;6CAEZ,MAAM,KACrB,OAAO,CAAC,mBAAmB,EAAE,CAAC;8BAElB,MAAM,SACZ,MAAM,UACL,MAAM,UACN,MAAM,KACb,IAAI;gCACqB,MAAM,KAAG,IAAI;4BACnB,YAAY,EAAE;iCAErB,MAAM,cACP,MAAM,aACP,MAAM,KAChB,IAAI;mCACwB,MAAM,KAAG,IAAI;+BACnB,eAAe,EAAE;2BACrB,OAAO,CAAC,IAAI,CAAC;0BACd,OAAO,CAAC,IAAI,CAAC;+BACR,IAAI;8BACL,IAAI;sBACZ,IAAI;qCACa,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAAI;mCAClC,OAAO,CAAC,OAAO,CAAC;8BACrB,IAAI;+BACH,IAAI;8BACH,oBAAoB,KAAG,aAAa,EAAE;0BAC5C,IAAI;4BACF,IAAI;uCACO,OAAO;+CACC,OAAO,CAAC,OAAO,CAAC;8BAC/B,MAAM,sBAAsB,aAAa,GAAG;;;qCAGrC,MAAM,sBAAsB;CAC9D,CAAC;AAEF,eAAe,IAAI,CAAC"}
@@ -22,6 +22,8 @@ const stub = {
22
22
  getEventLogs: (_options) => notSupported(),
23
23
  clearEventLogs: () => notSupported(),
24
24
  destroyEventLogs: () => notSupported(),
25
+ isBatteryOptimizationExempt: () => true,
26
+ requestBatteryOptimizationExemption: () => Promise.resolve(true),
25
27
  addListener: (_eventName, _listener) => ({
26
28
  remove: () => { },
27
29
  }),
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoBeaconModule.web.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAUA,MAAM,YAAY,GAAG,GAAU,EAAE;IAC/B,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;AAC1D,CAAC,CAAC;AAEF,MAAM,IAAI,GAAG;IACX,mBAAmB,EAAE,CACnB,MAAgB,EAChB,aAAsB,EACO,EAAE,CAAC,YAAY,EAAE;IAChD,sBAAsB,EAAE,CACtB,aAAsB,EACU,EAAE,CAAC,YAAY,EAAE;IACnD,UAAU,EAAE,CACV,WAAmB,EACnB,KAAa,EACb,MAAc,EACd,MAAc,EACR,EAAE,CAAC,YAAY,EAAE;IACzB,YAAY,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC3D,gBAAgB,EAAE,GAAmB,EAAE,CAAC,YAAY,EAAE;IACtD,aAAa,EAAE,CACb,WAAmB,EACnB,UAAkB,EAClB,SAAiB,EACX,EAAE,CAAC,YAAY,EAAE;IACzB,eAAe,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC9D,mBAAmB,EAAE,GAAsB,EAAE,CAAC,YAAY,EAAE;IAC5D,eAAe,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACpD,cAAc,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACnD,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,UAAU,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IACtC,qBAAqB,EAAE,CAAC,OAAgC,EAAQ,EAAE,CAAC,YAAY,EAAE;IACjF,uBAAuB,EAAE,GAAqB,EAAE,CAAC,YAAY,EAAE;IAC/D,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,YAAY,EAAE,CAAC,QAA+B,EAAmB,EAAE,CAAC,YAAY,EAAE;IAClF,cAAc,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC1C,gBAAgB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC5C,WAAW,EAAE,CAAC,UAAwC,EAAE,SAAc,EAAE,EAAE,CAAC,CAAC;QAC1E,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IACF,kBAAkB,EAAE,CAAC,UAAwC,EAAE,EAAE,GAAE,CAAC;CACrE,CAAC;AAEF,eAAe,IAAI,CAAC","sourcesContent":["import type {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\nconst notSupported = (): never => {\r\n throw new Error(\"expo-beacon is not supported on web.\");\r\n};\r\n\r\nconst stub = {\r\n scanForBeaconsAsync: (\r\n _uuids: string[],\r\n _scanDuration?: number,\r\n ): Promise<BeaconScanResult[]> => notSupported(),\r\n scanForEddystonesAsync: (\r\n _scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]> => notSupported(),\r\n pairBeacon: (\r\n _identifier: string,\r\n _uuid: string,\r\n _major: number,\r\n _minor: number,\r\n ): void => notSupported(),\r\n unpairBeacon: (_identifier: string): void => notSupported(),\r\n getPairedBeacons: (): PairedBeacon[] => notSupported(),\r\n pairEddystone: (\r\n _identifier: string,\r\n _namespace: string,\r\n _instance: string,\r\n ): void => notSupported(),\r\n unpairEddystone: (_identifier: string): void => notSupported(),\r\n getPairedEddystones: (): PairedEddystone[] => notSupported(),\r\n startMonitoring: (): Promise<void> => notSupported(),\r\n stopMonitoring: (): Promise<void> => notSupported(),\r\n startContinuousScan: (): void => notSupported(),\r\n stopContinuousScan: (): void => notSupported(),\r\n cancelScan: (): void => notSupported(),\r\n setNotificationConfig: (_config: Record<string, unknown>): void => notSupported(),\r\n requestPermissionsAsync: (): Promise<boolean> => notSupported(),\r\n enableEventLogging: (): void => notSupported(),\r\n disableEventLogging: (): void => notSupported(),\r\n getEventLogs: (_options?: EventLogQueryOptions): EventLogEntry[] => notSupported(),\r\n clearEventLogs: (): void => notSupported(),\r\n destroyEventLogs: (): void => notSupported(),\r\n addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({\r\n remove: () => {},\r\n }),\r\n removeAllListeners: (_eventName: keyof ExpoBeaconModuleEvents) => {},\r\n};\r\n\r\nexport default stub;\r\n"]}
1
+ {"version":3,"file":"ExpoBeaconModule.web.js","sourceRoot":"","sources":["../src/ExpoBeaconModule.web.ts"],"names":[],"mappings":"AAUA,MAAM,YAAY,GAAG,GAAU,EAAE;IAC/B,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;AAC1D,CAAC,CAAC;AAEF,MAAM,IAAI,GAAG;IACX,mBAAmB,EAAE,CACnB,MAAgB,EAChB,aAAsB,EACO,EAAE,CAAC,YAAY,EAAE;IAChD,sBAAsB,EAAE,CACtB,aAAsB,EACU,EAAE,CAAC,YAAY,EAAE;IACnD,UAAU,EAAE,CACV,WAAmB,EACnB,KAAa,EACb,MAAc,EACd,MAAc,EACR,EAAE,CAAC,YAAY,EAAE;IACzB,YAAY,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC3D,gBAAgB,EAAE,GAAmB,EAAE,CAAC,YAAY,EAAE;IACtD,aAAa,EAAE,CACb,WAAmB,EACnB,UAAkB,EAClB,SAAiB,EACX,EAAE,CAAC,YAAY,EAAE;IACzB,eAAe,EAAE,CAAC,WAAmB,EAAQ,EAAE,CAAC,YAAY,EAAE;IAC9D,mBAAmB,EAAE,GAAsB,EAAE,CAAC,YAAY,EAAE;IAC5D,eAAe,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACpD,cAAc,EAAE,GAAkB,EAAE,CAAC,YAAY,EAAE;IACnD,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,UAAU,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IACtC,qBAAqB,EAAE,CAAC,OAAgC,EAAQ,EAAE,CAAC,YAAY,EAAE;IACjF,uBAAuB,EAAE,GAAqB,EAAE,CAAC,YAAY,EAAE;IAC/D,kBAAkB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC9C,mBAAmB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC/C,YAAY,EAAE,CAAC,QAA+B,EAAmB,EAAE,CAAC,YAAY,EAAE;IAClF,cAAc,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC1C,gBAAgB,EAAE,GAAS,EAAE,CAAC,YAAY,EAAE;IAC5C,2BAA2B,EAAE,GAAY,EAAE,CAAC,IAAI;IAChD,mCAAmC,EAAE,GAAqB,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC;IAClF,WAAW,EAAE,CAAC,UAAwC,EAAE,SAAc,EAAE,EAAE,CAAC,CAAC;QAC1E,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IACF,kBAAkB,EAAE,CAAC,UAAwC,EAAE,EAAE,GAAE,CAAC;CACrE,CAAC;AAEF,eAAe,IAAI,CAAC","sourcesContent":["import type {\r\n ExpoBeaconModuleEvents,\r\n BeaconScanResult,\r\n EddystoneScanResult,\r\n PairedBeacon,\r\n PairedEddystone,\r\n EventLogQueryOptions,\r\n EventLogEntry,\r\n} from \"./ExpoBeacon.types\";\r\n\r\nconst notSupported = (): never => {\r\n throw new Error(\"expo-beacon is not supported on web.\");\r\n};\r\n\r\nconst stub = {\r\n scanForBeaconsAsync: (\r\n _uuids: string[],\r\n _scanDuration?: number,\r\n ): Promise<BeaconScanResult[]> => notSupported(),\r\n scanForEddystonesAsync: (\r\n _scanDuration?: number,\r\n ): Promise<EddystoneScanResult[]> => notSupported(),\r\n pairBeacon: (\r\n _identifier: string,\r\n _uuid: string,\r\n _major: number,\r\n _minor: number,\r\n ): void => notSupported(),\r\n unpairBeacon: (_identifier: string): void => notSupported(),\r\n getPairedBeacons: (): PairedBeacon[] => notSupported(),\r\n pairEddystone: (\r\n _identifier: string,\r\n _namespace: string,\r\n _instance: string,\r\n ): void => notSupported(),\r\n unpairEddystone: (_identifier: string): void => notSupported(),\r\n getPairedEddystones: (): PairedEddystone[] => notSupported(),\r\n startMonitoring: (): Promise<void> => notSupported(),\r\n stopMonitoring: (): Promise<void> => notSupported(),\r\n startContinuousScan: (): void => notSupported(),\r\n stopContinuousScan: (): void => notSupported(),\r\n cancelScan: (): void => notSupported(),\r\n setNotificationConfig: (_config: Record<string, unknown>): void => notSupported(),\r\n requestPermissionsAsync: (): Promise<boolean> => notSupported(),\r\n enableEventLogging: (): void => notSupported(),\r\n disableEventLogging: (): void => notSupported(),\r\n getEventLogs: (_options?: EventLogQueryOptions): EventLogEntry[] => notSupported(),\r\n clearEventLogs: (): void => notSupported(),\r\n destroyEventLogs: (): void => notSupported(),\r\n isBatteryOptimizationExempt: (): boolean => true,\r\n requestBatteryOptimizationExemption: (): Promise<boolean> => Promise.resolve(true),\r\n addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({\r\n remove: () => {},\r\n }),\r\n removeAllListeners: (_eventName: keyof ExpoBeaconModuleEvents) => {},\r\n};\r\n\r\nexport default stub;\r\n"]}
@@ -13,8 +13,10 @@ private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
13
13
  private let EVENT_LOGGING_ENABLED_KEY = "expo.beacon.event_logging_enabled"
14
14
 
15
15
  /// Number of consecutive ranging misses before emitting a distance-based exit event.
16
- /// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
17
- private let EXIT_MISS_THRESHOLD = 3
16
+ /// With ~1 s CoreLocation ranging cycles, 10 misses ≈ 10 s of silence before
17
+ /// declaring exit tolerant of brief ranging gaps while still responsive.
18
+ /// NOTE: Android uses the same value but with a different scan cycle (~2.1 s).
19
+ private let EXIT_MISS_THRESHOLD = 10
18
20
  /// Number of consecutive readings required to confirm a distance-based enter or exit transition.
19
21
  /// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
20
22
  private let HYSTERESIS_COUNT = 3
@@ -22,7 +24,8 @@ private let HYSTERESIS_COUNT = 3
22
24
  /// Eddystone monitoring timer interval in seconds.
23
25
  private let EDDYSTONE_MONITORING_TICK_INTERVAL: TimeInterval = 2.0
24
26
  /// Maximum age (in seconds) before a beacon is considered "not recently seen".
25
- private let EDDYSTONE_RECENTLY_SEEN_THRESHOLD: TimeInterval = 3.0
27
+ /// Set higher than a few tick intervals to tolerate brief BLE advertisement gaps.
28
+ private let EDDYSTONE_RECENTLY_SEEN_THRESHOLD: TimeInterval = 8.0
26
29
  /// Minimum interval between consecutive distance event emissions per identifier.
27
30
  private let DISTANCE_EVENT_THROTTLE_INTERVAL: TimeInterval = 1.0
28
31
 
@@ -313,6 +316,22 @@ public class ExpoBeaconModule: Module {
313
316
  self.defaults.set(json, forKey: NOTIFICATION_CONFIG_KEY)
314
317
  }
315
318
  }
319
+ if let dist = maxDistance, (!dist.isFinite || dist <= 0) {
320
+ promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0")
321
+ return
322
+ }
323
+ if let exitDist = exitDistance, (!exitDist.isFinite || exitDist <= 0) {
324
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0")
325
+ return
326
+ }
327
+ if exitDistance != nil && maxDistance == nil {
328
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set")
329
+ return
330
+ }
331
+ if let dist = maxDistance, let exitDist = exitDistance, exitDist < dist {
332
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance")
333
+ return
334
+ }
316
335
  if let dist = maxDistance {
317
336
  self.defaults.set(dist, forKey: MAX_DISTANCE_KEY)
318
337
  } else {
@@ -435,6 +454,16 @@ public class ExpoBeaconModule: Module {
435
454
  self.eventLogger = nil
436
455
  }
437
456
 
457
+ // MARK: - Battery Optimization (Android-only; no-op on iOS)
458
+
459
+ Function("isBatteryOptimizationExempt") { () -> Bool in
460
+ return true
461
+ }
462
+
463
+ AsyncFunction("requestBatteryOptimizationExemption") { (promise: Promise) in
464
+ promise.resolve(true)
465
+ }
466
+
438
467
  // MARK: - Lifecycle
439
468
 
440
469
  OnDestroy {
@@ -744,40 +773,45 @@ public class ExpoBeaconModule: Module {
744
773
  // reliably regardless of advertisement rate.
745
774
  let maxDist = self.defaults.object(forKey: MAX_DISTANCE_KEY) as? Double
746
775
  let exitDist = self.defaults.object(forKey: EXIT_DISTANCE_KEY) as? Double
747
- let action = evaluateDistanceHysteresis(
748
- identifier: identifier,
749
- distance: distance,
750
- maxDistance: maxDist,
751
- exitDistance: exitDist,
752
- entered: &eddystoneEnteredRegions,
753
- enterCtrs: &eddystoneEnterCounters,
754
- exitCtrs: &eddystoneExitCounters
755
- )
756
- switch action {
757
- case .enter:
758
- sendLoggedEvent("onEddystoneEnter", [
759
- "identifier": identifier,
760
- "namespace": ns,
761
- "instance": inst,
762
- "event": "enter",
763
- "distance": distance
764
- ])
765
- postBeaconNotification(identifier: identifier, eventType: "enter")
766
- scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
767
- case .exit:
768
- cancelEddystoneTimeout(identifier: identifier)
769
- sendLoggedEvent("onEddystoneExit", [
770
- "identifier": identifier,
771
- "namespace": ns,
772
- "instance": inst,
773
- "event": "exit",
774
- "distance": distance
775
- ])
776
- postBeaconNotification(identifier: identifier, eventType: "exit")
777
- case .none:
778
- break
776
+ let hasValidDistance = distance.isFinite && distance >= 0
777
+ if hasValidDistance || maxDist == nil {
778
+ let action = evaluateDistanceHysteresis(
779
+ identifier: identifier,
780
+ distance: distance,
781
+ maxDistance: maxDist,
782
+ exitDistance: exitDist,
783
+ entered: &eddystoneEnteredRegions,
784
+ enterCtrs: &eddystoneEnterCounters,
785
+ exitCtrs: &eddystoneExitCounters
786
+ )
787
+ switch action {
788
+ case .enter:
789
+ sendLoggedEvent("onEddystoneEnter", [
790
+ "identifier": identifier,
791
+ "namespace": ns,
792
+ "instance": inst,
793
+ "event": "enter",
794
+ "distance": distance
795
+ ])
796
+ postBeaconNotification(identifier: identifier, eventType: "enter")
797
+ scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
798
+ case .exit:
799
+ cancelEddystoneTimeout(identifier: identifier)
800
+ sendLoggedEvent("onEddystoneExit", [
801
+ "identifier": identifier,
802
+ "namespace": ns,
803
+ "instance": inst,
804
+ "event": "exit",
805
+ "distance": distance
806
+ ])
807
+ postBeaconNotification(identifier: identifier, eventType: "exit")
808
+ case .none:
809
+ break
810
+ }
779
811
  }
780
812
 
813
+ guard hasValidDistance else { break }
814
+
781
815
  // Throttle distance events — enter/exit above is evaluated on every
782
816
  // callback, but distance events are rate-limited to avoid flooding JS.
783
817
  let now = Date()
@@ -967,7 +1001,10 @@ public class ExpoBeaconModule: Module {
967
1001
  continue
968
1002
  }
969
1003
 
970
- // Not seen recently — increment miss counter
1004
+ // Not seen recently — break distance hysteresis streaks and, if the
1005
+ // beacon was already entered, increment miss counter toward exit.
1006
+ eddystoneEnterCounters[identifier] = 0
1007
+ eddystoneExitCounters[identifier] = 0
971
1008
  guard eddystoneEnteredRegions.contains(identifier) else { continue }
972
1009
 
973
1010
  let count = (eddystoneMissCounters[identifier] ?? 0) + 1
@@ -1242,6 +1279,8 @@ public class ExpoBeaconModule: Module {
1242
1279
  // duplicate events when both monitoring and continuous scan are active.
1243
1280
  } else {
1244
1281
  // No valid beacon reading — beacon may have disappeared
1282
+ enterCounters[identifier] = 0
1283
+ exitCounters[identifier] = 0
1245
1284
  let count = (missCounters[identifier] ?? 0) + 1
1246
1285
  missCounters[identifier] = count
1247
1286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.6.3",
3
+ "version": "0.6.6",
4
4
  "description": "Expo module for scanning, pairing, and monitoring iBeacons on Android and iOS",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -122,6 +122,20 @@ declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
122
122
  /** Request Bluetooth + Location permissions. Returns true if granted. */
123
123
  requestPermissionsAsync(): Promise<boolean>;
124
124
 
125
+ /**
126
+ * Check whether the app is exempt from Android battery optimizations.
127
+ * Always returns true on iOS and web (no equivalent concept).
128
+ */
129
+ isBatteryOptimizationExempt(): boolean;
130
+
131
+ /**
132
+ * Request exemption from Android battery optimizations.
133
+ * Opens the system dialog asking the user to whitelist this app.
134
+ * Returns true if the dialog was shown (or already exempt), false on failure.
135
+ * Always resolves true on iOS and web.
136
+ */
137
+ requestBatteryOptimizationExemption(): Promise<boolean>;
138
+
125
139
  /** Enable SQLite event logging. All beacon events will be persisted to a local database. */
126
140
  enableEventLogging(): void;
127
141
 
@@ -47,6 +47,8 @@ const stub = {
47
47
  getEventLogs: (_options?: EventLogQueryOptions): EventLogEntry[] => notSupported(),
48
48
  clearEventLogs: (): void => notSupported(),
49
49
  destroyEventLogs: (): void => notSupported(),
50
+ isBatteryOptimizationExempt: (): boolean => true,
51
+ requestBatteryOptimizationExemption: (): Promise<boolean> => Promise.resolve(true),
50
52
  addListener: (_eventName: keyof ExpoBeaconModuleEvents, _listener: any) => ({
51
53
  remove: () => {},
52
54
  }),