expo-beacon 0.6.2 → 0.6.4

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.
@@ -10,6 +10,17 @@ internal const val EDDYSTONE_PREFS_NAME = "expo.beacon.paired_eddystones"
10
10
  internal const val EDDYSTONE_PREFS_KEY = "paired_eddystones"
11
11
  internal const val NOTIFICATION_CONFIG_PREFS = "expo.beacon.notification_config"
12
12
  internal const val MONITORING_OPTIONS_PREFS = "expo.beacon.monitoring_options"
13
+ internal const val EVENT_LOGGING_PREFS = "expo.beacon.event_logging"
14
+ internal const val EVENT_LOGGING_ENABLED_KEY = "enabled"
15
+
16
+ /** Foreground-service scan window for background monitoring responsiveness. */
17
+ internal const val MONITORING_SCAN_PERIOD_MS = 1100L
18
+
19
+ /** Gap between scan windows while the foreground service is active. */
20
+ internal const val MONITORING_BETWEEN_SCAN_PERIOD_MS = 0L
21
+
22
+ /** Ignore monitor-based exits if ranging saw the beacon within this window. */
23
+ internal const val RECENT_RANGING_SIGHTING_GRACE_MS = 4000L
13
24
 
14
25
  /** Number of consecutive ranging misses before emitting a distance-based exit event. */
15
26
  internal const val EXIT_MISS_THRESHOLD = 3
@@ -11,12 +11,30 @@ import org.json.JSONObject
11
11
  * Thread-safe — all writes go through a single SQLiteOpenHelper.
12
12
  */
13
13
  internal class BeaconEventLogger(context: Context) :
14
- SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
14
+ SQLiteOpenHelper(context.applicationContext, DB_NAME, null, DB_VERSION) {
15
15
 
16
16
  companion object {
17
17
  private const val DB_NAME = "expo_beacon_events.db"
18
18
  private const val DB_VERSION = 1
19
19
  private const val TABLE = "events"
20
+
21
+ fun isLoggingEnabled(context: Context): Boolean {
22
+ return context.applicationContext
23
+ .getSharedPreferences(EVENT_LOGGING_PREFS, Context.MODE_PRIVATE)
24
+ .getBoolean(EVENT_LOGGING_ENABLED_KEY, false)
25
+ }
26
+
27
+ fun setLoggingEnabled(context: Context, enabled: Boolean) {
28
+ context.applicationContext
29
+ .getSharedPreferences(EVENT_LOGGING_PREFS, Context.MODE_PRIVATE)
30
+ .edit()
31
+ .putBoolean(EVENT_LOGGING_ENABLED_KEY, enabled)
32
+ .apply()
33
+ }
34
+
35
+ fun deleteLogDatabase(context: Context) {
36
+ context.applicationContext.deleteDatabase(DB_NAME)
37
+ }
20
38
  }
21
39
 
22
40
  override fun onCreate(db: SQLiteDatabase) {
@@ -90,9 +108,4 @@ internal class BeaconEventLogger(context: Context) :
90
108
  fun clearEvents() {
91
109
  writableDatabase.delete(TABLE, null, null)
92
110
  }
93
-
94
- fun destroyDatabase(context: Context) {
95
- close()
96
- context.deleteDatabase(DB_NAME)
97
- }
98
111
  }
@@ -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
@@ -34,8 +35,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
34
35
  // Distance filtering
35
36
  @Volatile private var maxDistance: Double? = null
36
37
  @Volatile private var exitDistance: Double? = null
37
- private val rangingRegions = java.util.concurrent.CopyOnWriteArraySet<Region>()
38
+ private val monitoredRegionIds = java.util.concurrent.CopyOnWriteArraySet<String>()
38
39
  private val enteredRegions = java.util.concurrent.CopyOnWriteArraySet<String>()
40
+ private val lastSeenAtMs = java.util.concurrent.ConcurrentHashMap<String, Long>()
39
41
 
40
42
  // Hysteresis counters (synchronized on distanceLock)
41
43
  private val distanceLock = Any()
@@ -55,6 +57,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
55
57
  private val timeoutRunnables = java.util.concurrent.ConcurrentHashMap<String, Runnable>()
56
58
  // Per-beacon timeout seconds lookup (identifier → seconds), loaded from paired data
57
59
  private val beaconTimeouts = java.util.concurrent.ConcurrentHashMap<String, Int>()
60
+ private var eventLogger: BeaconEventLogger? = null
58
61
 
59
62
  companion object {
60
63
  private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
@@ -88,10 +91,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
88
91
  beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
89
92
  BeaconParsers.ensureRegistered(manager)
90
93
  try { manager.setEnableScheduledScanJobs(false) } catch (e: IllegalStateException) { Log.w(TAG, "setEnableScheduledScanJobs failed", e) }
91
- manager.setBackgroundBetweenScanPeriod(5000L) // 5s between scans
92
- manager.setBackgroundScanPeriod(1100L) // 1.1s scan window
93
- manager.setForegroundScanPeriod(1000L) // 1s scan window for distance logging
94
- manager.setForegroundBetweenScanPeriod(0L) // no pause between scans
94
+ manager.setBackgroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
95
+ manager.setBackgroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
96
+ manager.setForegroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
97
+ manager.setForegroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
95
98
  }
96
99
  }
97
100
 
@@ -155,6 +158,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
155
158
  try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
156
159
  }
157
160
  distanceLogRegions.clear()
161
+ monitoredRegionIds.clear()
162
+ enteredRegions.clear()
163
+ lastSeenAtMs.clear()
164
+ timeoutHandler.removeCallbacksAndMessages(null)
165
+ timeoutRunnables.clear()
166
+ synchronized(distanceLock) {
167
+ enterCounters.clear()
168
+ exitCounters.clear()
169
+ missCounters.clear()
170
+ }
158
171
  monitoredRegions.forEach {
159
172
  try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
160
173
  }
@@ -170,6 +183,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
170
183
  Identifier.fromInt(b.getInt("minor"))
171
184
  )
172
185
  monitoredRegions.add(region)
186
+ monitoredRegionIds.add(region.uniqueId)
173
187
  try {
174
188
  beaconManager.startMonitoringBeaconsInRegion(region)
175
189
  } catch (e: RemoteException) {
@@ -199,6 +213,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
199
213
  null
200
214
  )
201
215
  monitoredRegions.add(region)
216
+ monitoredRegionIds.add(region.uniqueId)
202
217
  try {
203
218
  beaconManager.startMonitoringBeaconsInRegion(region)
204
219
  } catch (ex: RemoteException) {
@@ -223,51 +238,42 @@ class BeaconForegroundService : Service(), BeaconConsumer {
223
238
 
224
239
  // Distance logging only — emits distance broadcasts. Enter/exit logic lives in rangeNotifier.
225
240
  private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
241
+ if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
226
242
  val closest = beacons.filter { it.distance >= 0 }.minByOrNull { it.distance }
227
243
  if (closest != null) {
244
+ lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
228
245
  sendBeaconBroadcast(region, "distance", closest.distance)
229
246
  }
230
247
  }
231
248
 
232
249
  private val monitorNotifier = object : MonitorNotifier {
233
250
  override fun didEnterRegion(region: Region) {
234
- val maxDist = maxDistance
235
- if (maxDist != null) {
236
- // Mark region for distance confirmation — ranging is already active via distance logging
237
- rangingRegions.add(region)
238
- } else {
239
- enteredRegions.add(region.uniqueId)
240
- sendBeaconBroadcast(region, "enter", -1.0)
241
- showEnterExitNotification(region, "enter")
242
- scheduleTimeoutIfConfigured(region)
243
- }
251
+ // Enter is synthesized from ranging so distance and enter/exit stay in sync.
244
252
  }
245
253
 
246
254
  override fun didExitRegion(region: Region) {
247
- rangingRegions.remove(region)
248
-
249
- if (maxDistance != null) {
250
- // Distance ranging normally handles exit. But if the beacon was
251
- // in "entered" state when OS fires didExitRegion, we must emit
252
- // the exit event — ranging will no longer receive readings.
253
- val wasEntered = enteredRegions.remove(region.uniqueId)
254
- synchronized(distanceLock) {
255
- enterCounters.remove(region.uniqueId)
256
- exitCounters.remove(region.uniqueId)
257
- missCounters.remove(region.uniqueId)
258
- }
259
- if (wasEntered) {
260
- cancelTimeout(region.uniqueId)
261
- sendBeaconBroadcast(region, "exit", -1.0)
262
- showEnterExitNotification(region, "exit")
263
- }
255
+ if (!monitoredRegionIds.contains(region.uniqueId)) return
256
+ if (wasSeenRecently(region.uniqueId)) {
257
+ Log.d(TAG, "Ignoring stale didExitRegion for ${region.uniqueId}; beacon was seen by ranging recently")
264
258
  return
265
259
  }
266
260
 
267
- cancelTimeout(region.uniqueId)
268
- enteredRegions.remove(region.uniqueId)
269
- sendBeaconBroadcast(region, "exit", -1.0)
270
- showEnterExitNotification(region, "exit")
261
+ lastSeenAtMs.remove(region.uniqueId)
262
+
263
+ // Ranging-based hysteresis handles exit in the normal case. If the OS
264
+ // fires didExitRegion after ranging has already stopped, emit exit as a
265
+ // safety net only if the region was previously in the entered state.
266
+ val wasEntered = enteredRegions.remove(region.uniqueId)
267
+ synchronized(distanceLock) {
268
+ enterCounters.remove(region.uniqueId)
269
+ exitCounters.remove(region.uniqueId)
270
+ missCounters.remove(region.uniqueId)
271
+ }
272
+ if (wasEntered) {
273
+ cancelTimeout(region.uniqueId)
274
+ sendBeaconBroadcast(region, "exit", -1.0)
275
+ showEnterExitNotification(region, "exit")
276
+ }
271
277
  }
272
278
 
273
279
  override fun didDetermineStateForRegion(state: Int, region: Region) {
@@ -276,11 +282,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
276
282
  }
277
283
 
278
284
  // Single source of truth for distance-based enter/exit with hysteresis.
279
- // Processes regions added to rangingRegions by monitorNotifier.didEnterRegion,
280
- // and also handles exit via miss counting when beacons disappear.
285
+ // Processes only actual monitoring regions and handles exit via miss counting
286
+ // when beacons disappear.
281
287
  private val rangeNotifier = RangeNotifier { beacons, region ->
282
- val maxDist = maxDistance ?: return@RangeNotifier
283
- if (!rangingRegions.contains(region) && !enteredRegions.contains(region.uniqueId)) return@RangeNotifier
288
+ val maxDist = maxDistance
289
+ if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
284
290
 
285
291
  val beacon = beacons
286
292
  .filter { it.distance >= 0 }
@@ -289,13 +295,13 @@ class BeaconForegroundService : Service(), BeaconConsumer {
289
295
  synchronized(distanceLock) {
290
296
  if (beacon != null) {
291
297
  // Got a valid reading — reset miss counter
298
+ lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
292
299
  missCounters[region.uniqueId] = 0
293
300
 
294
301
  val action = evaluateDistanceHysteresis(region.uniqueId, beacon.distance, maxDist)
295
302
  when (action) {
296
303
  HysteresisAction.ENTER -> {
297
304
  enteredRegions.add(region.uniqueId)
298
- rangingRegions.remove(region)
299
305
  sendBeaconBroadcast(region, "enter", beacon.distance)
300
306
  showEnterExitNotification(region, "enter")
301
307
  scheduleTimeoutIfConfigured(region)
@@ -303,7 +309,6 @@ class BeaconForegroundService : Service(), BeaconConsumer {
303
309
  HysteresisAction.EXIT -> {
304
310
  cancelTimeout(region.uniqueId)
305
311
  enteredRegions.remove(region.uniqueId)
306
- rangingRegions.add(region)
307
312
  sendBeaconBroadcast(region, "exit", beacon.distance)
308
313
  showEnterExitNotification(region, "exit")
309
314
  }
@@ -348,8 +353,23 @@ class BeaconForegroundService : Service(), BeaconConsumer {
348
353
  private fun evaluateDistanceHysteresis(
349
354
  regionId: String,
350
355
  distance: Double,
351
- maxDist: Double
356
+ maxDist: Double?
352
357
  ): HysteresisAction {
358
+ if (maxDist == null) {
359
+ exitCounters[regionId] = 0
360
+ if (enteredRegions.contains(regionId)) {
361
+ enterCounters[regionId] = 0
362
+ return HysteresisAction.NONE
363
+ }
364
+ val count = (enterCounters[regionId] ?: 0) + 1
365
+ enterCounters[regionId] = count
366
+ if (count >= HYSTERESIS_COUNT) {
367
+ enterCounters[regionId] = 0
368
+ return HysteresisAction.ENTER
369
+ }
370
+ return HysteresisAction.NONE
371
+ }
372
+
353
373
  val exitDist = effectiveExitDistance(maxDist)
354
374
  if (distance <= maxDist) {
355
375
  // Inside enter threshold
@@ -377,6 +397,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
377
397
  return HysteresisAction.NONE
378
398
  }
379
399
 
400
+ private fun wasSeenRecently(regionId: String): Boolean {
401
+ val lastSeen = lastSeenAtMs[regionId] ?: return false
402
+ return SystemClock.elapsedRealtime() - lastSeen <= RECENT_RANGING_SIGHTING_GRACE_MS
403
+ }
404
+
380
405
  // MARK: - Timeout timer helpers
381
406
 
382
407
  private fun scheduleTimeoutIfConfigured(region: Region) {
@@ -404,6 +429,26 @@ class BeaconForegroundService : Service(), BeaconConsumer {
404
429
  val id1Str = region.id1?.toString() ?: ""
405
430
  val isEddystone = id1Str.startsWith("0x")
406
431
 
432
+ val params = if (isEddystone) {
433
+ buildMap<String, Any?> {
434
+ put("identifier", region.uniqueId)
435
+ put("namespace", id1Str.removePrefix("0x"))
436
+ put("instance", region.id2?.toString()?.removePrefix("0x") ?: "")
437
+ put("event", eventType)
438
+ put("distance", distance)
439
+ }
440
+ } else {
441
+ buildMap<String, Any?> {
442
+ put("identifier", region.uniqueId)
443
+ put("uuid", id1Str)
444
+ put("major", region.id2?.toInt() ?: 0)
445
+ put("minor", region.id3?.toInt() ?: 0)
446
+ put("event", eventType)
447
+ put("distance", distance)
448
+ }
449
+ }
450
+ monitoringEventName(isEddystone, eventType)?.let { logBeaconEvent(it, params) }
451
+
407
452
  val intent = Intent(ACTION_BEACON_EVENT).apply {
408
453
  putExtra("identifier", region.uniqueId)
409
454
  putExtra("event", eventType)
@@ -424,6 +469,36 @@ class BeaconForegroundService : Service(), BeaconConsumer {
424
469
  sendBroadcast(intent)
425
470
  }
426
471
 
472
+ private fun monitoringEventName(isEddystone: Boolean, eventType: String): String? {
473
+ return when (eventType) {
474
+ "enter" -> if (isEddystone) "onEddystoneEnter" else "onBeaconEnter"
475
+ "exit" -> if (isEddystone) "onEddystoneExit" else "onBeaconExit"
476
+ "distance" -> if (isEddystone) "onEddystoneDistance" else "onBeaconDistance"
477
+ "timeout" -> if (isEddystone) "onEddystoneTimeout" else "onBeaconTimeout"
478
+ else -> null
479
+ }
480
+ }
481
+
482
+ @Synchronized
483
+ private fun getOrCreateEventLogger(): BeaconEventLogger {
484
+ return eventLogger ?: BeaconEventLogger(applicationContext).also { eventLogger = it }
485
+ }
486
+
487
+ @Synchronized
488
+ private fun releaseEventLogger() {
489
+ eventLogger?.close()
490
+ eventLogger = null
491
+ }
492
+
493
+ private fun logBeaconEvent(eventType: String, params: Map<String, Any?>) {
494
+ if (!BeaconEventLogger.isLoggingEnabled(this)) {
495
+ releaseEventLogger()
496
+ return
497
+ }
498
+ val identifier = params["identifier"] as? String
499
+ getOrCreateEventLogger().logEvent(eventType, identifier, params)
500
+ }
501
+
427
502
  private fun showEnterExitNotification(region: Region, eventType: String) {
428
503
  val config = readNotificationConfig()
429
504
  val eventsConfig = config.optJSONObject("beaconEvents")
@@ -527,13 +602,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
527
602
  timeoutHandler.removeCallbacksAndMessages(null)
528
603
  timeoutRunnables.clear()
529
604
  beaconTimeouts.clear()
605
+ lastSeenAtMs.clear()
606
+ monitoredRegionIds.clear()
607
+ releaseEventLogger()
530
608
  beaconManager.removeMonitorNotifier(monitorNotifier)
531
609
  beaconManager.removeRangeNotifier(rangeNotifier)
532
610
  beaconManager.removeRangeNotifier(distanceLoggingRangeNotifier)
533
- rangingRegions.forEach {
534
- try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
535
- }
536
- rangingRegions.clear()
537
611
  distanceLogRegions.forEach {
538
612
  try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
539
613
  }
@@ -294,6 +294,22 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
294
294
  }
295
295
  }
296
296
  }
297
+ if (maxDistance != null && (!maxDistance.isFinite() || maxDistance <= 0.0)) {
298
+ promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0", null)
299
+ return@AsyncFunction
300
+ }
301
+ if (exitDistance != null && (!exitDistance.isFinite() || exitDistance <= 0.0)) {
302
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0", null)
303
+ return@AsyncFunction
304
+ }
305
+ if (exitDistance != null && maxDistance == null) {
306
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set", null)
307
+ return@AsyncFunction
308
+ }
309
+ if (maxDistance != null && exitDistance != null && exitDistance < maxDistance) {
310
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance", null)
311
+ return@AsyncFunction
312
+ }
297
313
  ctx.getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
298
314
  .edit().apply {
299
315
  if (maxDistance != null) putString("max_distance", maxDistance.toString())
@@ -385,15 +401,17 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
385
401
  if (eventLogger == null) {
386
402
  eventLogger = BeaconEventLogger(ctx)
387
403
  }
404
+ BeaconEventLogger.setLoggingEnabled(ctx, true)
388
405
  loggingEnabled = true
389
406
  }
390
407
 
391
408
  Function("disableEventLogging") {
409
+ appContext.reactContext?.let { BeaconEventLogger.setLoggingEnabled(it, false) }
392
410
  loggingEnabled = false
393
411
  }
394
412
 
395
413
  Function("getEventLogs") { options: Map<String, Any?>? ->
396
- val logger = eventLogger ?: return@Function emptyList<Map<String, Any?>>()
414
+ val logger = getOrCreateEventLogger() ?: return@Function emptyList<Map<String, Any?>>()
397
415
  val limit = (options?.get("limit") as? Number)?.toInt() ?: 1000
398
416
  val eventType = options?.get("eventType") as? String
399
417
  val sinceTimestamp = (options?.get("sinceTimestamp") as? Number)?.toLong()
@@ -401,14 +419,16 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
401
419
  }
402
420
 
403
421
  Function("clearEventLogs") {
404
- eventLogger?.clearEvents()
422
+ getOrCreateEventLogger()?.clearEvents()
405
423
  }
406
424
 
407
425
  Function("destroyEventLogs") {
408
426
  loggingEnabled = false
409
427
  val ctx = appContext.reactContext ?: return@Function null
410
- eventLogger?.destroyDatabase(ctx)
428
+ BeaconEventLogger.setLoggingEnabled(ctx, false)
429
+ eventLogger?.close()
411
430
  eventLogger = null
431
+ BeaconEventLogger.deleteLogDatabase(ctx)
412
432
  }
413
433
 
414
434
  OnDestroy {
@@ -699,7 +719,6 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
699
719
  private fun registerEventReceiver() {
700
720
  if (eventReceiver != null) return
701
721
  val receiver = BeaconEventReceiver { eventName, params ->
702
- logBeaconEvent(eventName, params)
703
722
  sendEvent(eventName, params)
704
723
  }
705
724
  eventReceiver = receiver
@@ -740,10 +759,26 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
740
759
  }
741
760
  }
742
761
 
762
+ private fun getOrCreateEventLogger(): BeaconEventLogger? {
763
+ val context = appContext.reactContext ?: return null
764
+ return eventLogger ?: BeaconEventLogger(context).also { eventLogger = it }
765
+ }
766
+
767
+ private fun isEventLoggingEnabled(): Boolean {
768
+ if (loggingEnabled) return true
769
+ val context = appContext.reactContext ?: return false
770
+ if (!BeaconEventLogger.isLoggingEnabled(context)) return false
771
+ loggingEnabled = true
772
+ if (eventLogger == null) {
773
+ eventLogger = BeaconEventLogger(context)
774
+ }
775
+ return true
776
+ }
777
+
743
778
  /** Log an event to SQLite if logging is enabled. */
744
779
  private fun logBeaconEvent(eventType: String, params: Map<String, Any?>) {
745
- if (!loggingEnabled) return
780
+ if (!isEventLoggingEnabled()) return
746
781
  val identifier = params["identifier"] as? String
747
- eventLogger?.logEvent(eventType, identifier, params)
782
+ getOrCreateEventLogger()?.logEvent(eventType, identifier, params)
748
783
  }
749
784
  }
@@ -4,17 +4,28 @@ import SQLite3
4
4
  /// SQLite-backed event logger for beacon events (iOS).
5
5
  /// All access is expected from the main thread (same as ExpoBeaconModule).
6
6
  final class BeaconEventLogger {
7
+ private static let databaseFileName = "expo_beacon_events.db"
8
+
9
+ private static var databaseURL: URL {
10
+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
11
+ return dir.appendingPathComponent(databaseFileName)
12
+ }
13
+
7
14
  private var db: OpaquePointer?
8
15
  private let dbPath: String
9
16
 
10
17
  init() {
11
- let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
18
+ let dir = Self.databaseURL.deletingLastPathComponent()
12
19
  try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
13
- dbPath = dir.appendingPathComponent("expo_beacon_events.db").path
20
+ dbPath = Self.databaseURL.path
14
21
  openDatabase()
15
22
  createTableIfNeeded()
16
23
  }
17
24
 
25
+ static func destroyPersistentStore() {
26
+ try? FileManager.default.removeItem(at: databaseURL)
27
+ }
28
+
18
29
  private func openDatabase() {
19
30
  guard sqlite3_open(dbPath, &db) == SQLITE_OK else {
20
31
  print("[ExpoBeacon] Failed to open event log database")
@@ -126,7 +137,7 @@ final class BeaconEventLogger {
126
137
  sqlite3_close(db)
127
138
  db = nil
128
139
  }
129
- try? FileManager.default.removeItem(atPath: dbPath)
140
+ Self.destroyPersistentStore()
130
141
  }
131
142
 
132
143
  deinit {
@@ -10,6 +10,7 @@ private let IS_MONITORING_KEY = "expo.beacon.is_monitoring"
10
10
  private let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
11
11
  private let EXIT_DISTANCE_KEY = "expo.beacon.exit_distance"
12
12
  private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
13
+ private let EVENT_LOGGING_ENABLED_KEY = "expo.beacon.event_logging_enabled"
13
14
 
14
15
  /// Number of consecutive ranging misses before emitting a distance-based exit event.
15
16
  /// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
@@ -312,6 +313,22 @@ public class ExpoBeaconModule: Module {
312
313
  self.defaults.set(json, forKey: NOTIFICATION_CONFIG_KEY)
313
314
  }
314
315
  }
316
+ if let dist = maxDistance, (!dist.isFinite || dist <= 0) {
317
+ promise.reject("INVALID_MAX_DISTANCE", "maxDistance must be a finite number greater than 0")
318
+ return
319
+ }
320
+ if let exitDist = exitDistance, (!exitDist.isFinite || exitDist <= 0) {
321
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be a finite number greater than 0")
322
+ return
323
+ }
324
+ if exitDistance != nil && maxDistance == nil {
325
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance requires maxDistance to be set")
326
+ return
327
+ }
328
+ if let dist = maxDistance, let exitDist = exitDistance, exitDist < dist {
329
+ promise.reject("INVALID_EXIT_DISTANCE", "exitDistance must be greater than or equal to maxDistance")
330
+ return
331
+ }
315
332
  if let dist = maxDistance {
316
333
  self.defaults.set(dist, forKey: MAX_DISTANCE_KEY)
317
334
  } else {
@@ -402,15 +419,17 @@ public class ExpoBeaconModule: Module {
402
419
  if self.eventLogger == nil {
403
420
  self.eventLogger = BeaconEventLogger()
404
421
  }
422
+ self.defaults.set(true, forKey: EVENT_LOGGING_ENABLED_KEY)
405
423
  self.loggingEnabled = true
406
424
  }
407
425
 
408
426
  Function("disableEventLogging") { () -> Void in
427
+ self.defaults.set(false, forKey: EVENT_LOGGING_ENABLED_KEY)
409
428
  self.loggingEnabled = false
410
429
  }
411
430
 
412
431
  Function("getEventLogs") { (options: [String: Any]?) -> [[String: Any]] in
413
- guard let logger = self.eventLogger else { return [] }
432
+ let logger = self.getOrCreateEventLogger()
414
433
  let limit = (options?["limit"] as? Int) ?? 1000
415
434
  let eventType = options?["eventType"] as? String
416
435
  let sinceTimestamp: Int64? = (options?["sinceTimestamp"] as? NSNumber)?.int64Value
@@ -418,12 +437,17 @@ public class ExpoBeaconModule: Module {
418
437
  }
419
438
 
420
439
  Function("clearEventLogs") { () -> Void in
421
- self.eventLogger?.clearEvents()
440
+ self.getOrCreateEventLogger().clearEvents()
422
441
  }
423
442
 
424
443
  Function("destroyEventLogs") { () -> Void in
444
+ self.defaults.set(false, forKey: EVENT_LOGGING_ENABLED_KEY)
425
445
  self.loggingEnabled = false
426
- self.eventLogger?.destroy()
446
+ if let logger = self.eventLogger {
447
+ logger.destroy()
448
+ } else {
449
+ BeaconEventLogger.destroyPersistentStore()
450
+ }
427
451
  self.eventLogger = nil
428
452
  }
429
453
 
@@ -736,40 +760,45 @@ public class ExpoBeaconModule: Module {
736
760
  // reliably regardless of advertisement rate.
737
761
  let maxDist = self.defaults.object(forKey: MAX_DISTANCE_KEY) as? Double
738
762
  let exitDist = self.defaults.object(forKey: EXIT_DISTANCE_KEY) as? Double
739
- let action = evaluateDistanceHysteresis(
740
- identifier: identifier,
741
- distance: distance,
742
- maxDistance: maxDist,
743
- exitDistance: exitDist,
744
- entered: &eddystoneEnteredRegions,
745
- enterCtrs: &eddystoneEnterCounters,
746
- exitCtrs: &eddystoneExitCounters
747
- )
748
- switch action {
749
- case .enter:
750
- sendLoggedEvent("onEddystoneEnter", [
751
- "identifier": identifier,
752
- "namespace": ns,
753
- "instance": inst,
754
- "event": "enter",
755
- "distance": distance
756
- ])
757
- postBeaconNotification(identifier: identifier, eventType: "enter")
758
- scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
759
- case .exit:
760
- cancelEddystoneTimeout(identifier: identifier)
761
- sendLoggedEvent("onEddystoneExit", [
762
- "identifier": identifier,
763
- "namespace": ns,
764
- "instance": inst,
765
- "event": "exit",
766
- "distance": distance
767
- ])
768
- postBeaconNotification(identifier: identifier, eventType: "exit")
769
- case .none:
770
- break
763
+ let hasValidDistance = distance.isFinite && distance >= 0
764
+ if hasValidDistance || maxDist == nil {
765
+ let action = evaluateDistanceHysteresis(
766
+ identifier: identifier,
767
+ distance: distance,
768
+ maxDistance: maxDist,
769
+ exitDistance: exitDist,
770
+ entered: &eddystoneEnteredRegions,
771
+ enterCtrs: &eddystoneEnterCounters,
772
+ exitCtrs: &eddystoneExitCounters
773
+ )
774
+ switch action {
775
+ case .enter:
776
+ sendLoggedEvent("onEddystoneEnter", [
777
+ "identifier": identifier,
778
+ "namespace": ns,
779
+ "instance": inst,
780
+ "event": "enter",
781
+ "distance": distance
782
+ ])
783
+ postBeaconNotification(identifier: identifier, eventType: "enter")
784
+ scheduleEddystoneTimeout(identifier: identifier, namespace: ns, instance: inst)
785
+ case .exit:
786
+ cancelEddystoneTimeout(identifier: identifier)
787
+ sendLoggedEvent("onEddystoneExit", [
788
+ "identifier": identifier,
789
+ "namespace": ns,
790
+ "instance": inst,
791
+ "event": "exit",
792
+ "distance": distance
793
+ ])
794
+ postBeaconNotification(identifier: identifier, eventType: "exit")
795
+ case .none:
796
+ break
797
+ }
771
798
  }
772
799
 
800
+ guard hasValidDistance else { break }
801
+
773
802
  // Throttle distance events — enter/exit above is evaluated on every
774
803
  // callback, but distance events are rate-limited to avoid flooding JS.
775
804
  let now = Date()
@@ -855,13 +884,36 @@ public class ExpoBeaconModule: Module {
855
884
 
856
885
  /// Sends an event to JS and logs it to SQLite if logging is enabled.
857
886
  private func sendLoggedEvent(_ eventName: String, _ params: [String: Any]) {
858
- if loggingEnabled {
887
+ if isEventLoggingEnabled() {
859
888
  let identifier = params["identifier"] as? String
860
- eventLogger?.logEvent(eventType: eventName, identifier: identifier, data: params)
889
+ getOrCreateEventLogger().logEvent(eventType: eventName, identifier: identifier, data: params)
861
890
  }
862
891
  sendEvent(eventName, params)
863
892
  }
864
893
 
894
+ private func getOrCreateEventLogger() -> BeaconEventLogger {
895
+ if let logger = eventLogger {
896
+ return logger
897
+ }
898
+ let logger = BeaconEventLogger()
899
+ eventLogger = logger
900
+ return logger
901
+ }
902
+
903
+ private func isEventLoggingEnabled() -> Bool {
904
+ if loggingEnabled {
905
+ return true
906
+ }
907
+ guard defaults.bool(forKey: EVENT_LOGGING_ENABLED_KEY) else {
908
+ return false
909
+ }
910
+ loggingEnabled = true
911
+ if eventLogger == nil {
912
+ eventLogger = BeaconEventLogger()
913
+ }
914
+ return true
915
+ }
916
+
865
917
  private func loadPairedBeaconsRaw() -> [[String: Any]] {
866
918
  if let cached = cachedPairedBeacons { return cached }
867
919
  let value = self.defaults.array(forKey: PAIRED_BEACONS_KEY) as? [[String: Any]] ?? []
@@ -881,7 +933,8 @@ public class ExpoBeaconModule: Module {
881
933
  guard !defaults.bool(forKey: migrationKey) else { return }
882
934
  let keysToMigrate = [
883
935
  PAIRED_BEACONS_KEY, PAIRED_EDDYSTONES_KEY,
884
- IS_MONITORING_KEY, MAX_DISTANCE_KEY, NOTIFICATION_CONFIG_KEY
936
+ IS_MONITORING_KEY, MAX_DISTANCE_KEY, NOTIFICATION_CONFIG_KEY,
937
+ EVENT_LOGGING_ENABLED_KEY
885
938
  ]
886
939
  for key in keysToMigrate {
887
940
  if let value = UserDefaults.standard.object(forKey: key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",