expo-beacon 0.10.1 → 0.10.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.
@@ -12,9 +12,9 @@ import android.os.Looper
12
12
  import android.os.RemoteException
13
13
  import android.os.SystemClock
14
14
  import android.util.Log
15
- import java.util.concurrent.atomic.AtomicInteger
16
15
  import androidx.core.app.NotificationCompat
17
16
  import androidx.core.app.NotificationManagerCompat
17
+ import java.util.concurrent.atomic.AtomicInteger
18
18
  import org.altbeacon.beacon.*
19
19
  import org.json.JSONArray
20
20
 
@@ -22,26 +22,22 @@ private const val CHANNEL_ID = "expo_beacon_channel"
22
22
  private const val CARPLAY_CHANNEL_ID = "expo_beacon_carplay_channel"
23
23
  internal const val FOREGROUND_NOTIF_ID = 1001
24
24
  /**
25
- * Base ID for per-beacon enter/exit notifications; incremented per unique region.
26
- * With FOREGROUND_NOTIF_ID at 1001, this allows up to 999 unique regions
27
- * before ID collision. Sufficient for real-world beacon deployments.
25
+ * Base ID for per-beacon enter/exit notifications; incremented per unique region. With
26
+ * FOREGROUND_NOTIF_ID at 1001, this allows up to 999 unique regions before ID collision. Sufficient
27
+ * for real-world beacon deployments.
28
28
  */
29
29
  private const val ENTER_EXIT_NOTIF_BASE_ID = 2000
30
30
  /**
31
- * Fixed notification IDs for CarPlay connect / disconnect events. Each event
32
- * type uses a single ID so repeated events of the same type replace the prior
33
- * notification rather than stacking, while connect and disconnect remain
34
- * independently visible.
31
+ * Fixed notification IDs for CarPlay connect / disconnect events. Each event type uses a single ID
32
+ * so repeated events of the same type replace the prior notification rather than stacking, while
33
+ * connect and disconnect remain independently visible.
35
34
  */
36
35
  private const val CARPLAY_CONNECTED_NOTIF_ID = 3000
37
36
  private const val CARPLAY_DISCONNECTED_NOTIF_ID = 3001
38
37
 
39
38
  class BeaconForegroundService : Service(), BeaconConsumer {
40
39
 
41
- data class MonitoringRuntimeState(
42
- val isEntered: Boolean,
43
- val distance: Double?
44
- )
40
+ data class MonitoringRuntimeState(val isEntered: Boolean, val distance: Double?)
45
41
 
46
42
  private lateinit var beaconManager: BeaconManager
47
43
  private val monitoredRegions = mutableListOf<Region>()
@@ -109,14 +105,19 @@ class BeaconForegroundService : Service(), BeaconConsumer {
109
105
  ensureNotificationChannel(this)
110
106
  ensureCarPlayNotificationChannel(this)
111
107
  apiForwarder = BeaconApiForwarder(this)
112
- beaconManager = BeaconManager.getInstanceForApplication(this).also { manager ->
113
- BeaconParsers.ensureRegistered(manager)
114
- try { manager.setEnableScheduledScanJobs(false) } catch (e: IllegalStateException) { Log.w(TAG, "setEnableScheduledScanJobs failed", e) }
115
- manager.setBackgroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
116
- manager.setBackgroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
117
- manager.setForegroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
118
- manager.setForegroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
119
- }
108
+ beaconManager =
109
+ BeaconManager.getInstanceForApplication(this).also { manager ->
110
+ BeaconParsers.ensureRegistered(manager)
111
+ try {
112
+ manager.setEnableScheduledScanJobs(false)
113
+ } catch (e: IllegalStateException) {
114
+ Log.w(TAG, "setEnableScheduledScanJobs failed", e)
115
+ }
116
+ manager.setBackgroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
117
+ manager.setBackgroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
118
+ manager.setForegroundScanPeriod(MONITORING_SCAN_PERIOD_MS)
119
+ manager.setForegroundBetweenScanPeriod(MONITORING_BETWEEN_SCAN_PERIOD_MS)
120
+ }
120
121
  // Increase AltBeacon's region exit period so didExitRegion doesn't fire
121
122
  // prematurely during brief BLE scan gaps.
122
123
  BeaconManager.setRegionExitPeriod(REGION_EXIT_PERIOD_MS)
@@ -145,9 +146,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
145
146
  try {
146
147
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
147
148
  startForeground(
148
- FOREGROUND_NOTIF_ID,
149
- buildForegroundNotification(),
150
- ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
149
+ FOREGROUND_NOTIF_ID,
150
+ buildForegroundNotification(),
151
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
151
152
  )
152
153
  } else {
153
154
  startForeground(FOREGROUND_NOTIF_ID, buildForegroundNotification())
@@ -158,14 +159,19 @@ class BeaconForegroundService : Service(), BeaconConsumer {
158
159
  // if the service start window was missed (e.g. BT not yet initialized at boot).
159
160
  val retryCount = intent?.getIntExtra(EXTRA_RETRY_COUNT, 0) ?: 0
160
161
  Log.e(TAG, "startForeground failed (retry=$retryCount) — stopping service", e)
161
- sendErrorBroadcast(null, "SERVICE_START_FAILED", "startForeground failed (retry=$retryCount): ${e.message}")
162
+ sendErrorBroadcast(
163
+ null,
164
+ "SERVICE_START_FAILED",
165
+ "startForeground failed (retry=$retryCount): ${e.message}"
166
+ )
162
167
  // Schedule a retry so monitoring can recover without user interaction, capped to
163
168
  // avoid infinite crash loops. Retry if EITHER beacon monitoring OR CarPlay
164
169
  // observation is supposed to be active — without the CarPlay branch the
165
170
  // service silently dies when the very first startCarPlayMonitoring() call
166
171
  // races with a transient BT permission / FGS-restriction failure.
167
172
  if (retryCount < MAX_STARTFOREGROUND_RETRIES &&
168
- (isMonitoringActive(this) || isCarPlayEnabled(this))) {
173
+ (isMonitoringActive(this) || isCarPlayEnabled(this))
174
+ ) {
169
175
  scheduleServiceRetry(retryCount + 1)
170
176
  }
171
177
  stopSelf()
@@ -211,36 +217,40 @@ class BeaconForegroundService : Service(), BeaconConsumer {
211
217
  }
212
218
 
213
219
  /**
214
- * Schedules a one-shot alarm to restart this service via startForegroundService().
215
- * Used when startForeground() fails transiently (e.g. BT not yet ready at boot).
220
+ * Schedules a one-shot alarm to restart this service via startForegroundService(). Used when
221
+ * startForeground() fails transiently (e.g. BT not yet ready at boot).
216
222
  */
217
223
  private fun scheduleServiceRetry(retryCount: Int) {
218
224
  val alarmManager = getSystemService(AlarmManager::class.java) ?: return
219
- val retryIntent = Intent(this, BeaconForegroundService::class.java)
220
- .putExtra(EXTRA_RETRY_COUNT, retryCount)
221
- val pendingIntent = PendingIntent.getService(
222
- this,
223
- RETRY_SERVICE_REQUEST_CODE,
224
- retryIntent,
225
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
226
- )
225
+ val retryIntent =
226
+ Intent(this, BeaconForegroundService::class.java)
227
+ .putExtra(EXTRA_RETRY_COUNT, retryCount)
228
+ val pendingIntent =
229
+ PendingIntent.getService(
230
+ this,
231
+ RETRY_SERVICE_REQUEST_CODE,
232
+ retryIntent,
233
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
234
+ )
227
235
  alarmManager.setExactAndAllowWhileIdle(
228
- AlarmManager.ELAPSED_REALTIME_WAKEUP,
229
- SystemClock.elapsedRealtime() + RETRY_DELAY_MS,
230
- pendingIntent
236
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
237
+ SystemClock.elapsedRealtime() + RETRY_DELAY_MS,
238
+ pendingIntent
231
239
  )
232
240
  Log.w(TAG, "startForeground retry $retryCount scheduled in ${RETRY_DELAY_MS}ms")
233
241
  }
234
242
 
235
243
  /**
236
- * Called by the system on Android 14+ (API 34) if a foreground service with a time-limited
237
- * type exceeds its allowed duration. connectedDevice has no documented time limit as of
238
- * Android 17, but this override ensures we handle a future limit gracefully rather than
239
- * receiving an ANR.
244
+ * Called by the system on Android 14+ (API 34) if a foreground service with a time-limited type
245
+ * exceeds its allowed duration. connectedDevice has no documented time limit as of Android 17,
246
+ * but this override ensures we handle a future limit gracefully rather than receiving an ANR.
240
247
  */
241
248
  @Suppress("NewApi")
242
249
  override fun onTimeout(startId: Int, fgsType: Int) {
243
- Log.w(TAG, "BeaconForegroundService.onTimeout(startId=$startId, fgsType=$fgsType) — scheduling restart")
250
+ Log.w(
251
+ TAG,
252
+ "BeaconForegroundService.onTimeout(startId=$startId, fgsType=$fgsType) — scheduling restart"
253
+ )
244
254
  if (isMonitoringActive(this)) {
245
255
  scheduleServiceRetry(0)
246
256
  }
@@ -251,7 +261,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
251
261
  if (!isMonitoringActive(this)) {
252
262
  // stopMonitoring() raced the AltBeacon bind — drop the connection
253
263
  // instead of arming regions that were just disabled.
254
- try { beaconManager.unbind(this) } catch (_: Throwable) {}
264
+ try {
265
+ beaconManager.unbind(this)
266
+ } catch (_: Throwable) {}
255
267
  return
256
268
  }
257
269
  serviceConnected = true
@@ -262,8 +274,8 @@ class BeaconForegroundService : Service(), BeaconConsumer {
262
274
  }
263
275
 
264
276
  /**
265
- * (Re-)read monitoring options from prefs. Called on every region load so a
266
- * second startMonitoring on a live bound service picks up new options.
277
+ * (Re-)read monitoring options from prefs. Called on every region load so a second
278
+ * startMonitoring on a live bound service picks up new options.
267
279
  */
268
280
  private fun applyMonitoringOptions() {
269
281
  val optPrefs = getSharedPreferences(MONITORING_OPTIONS_PREFS, Context.MODE_PRIVATE)
@@ -271,7 +283,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
271
283
  exitDistance = optPrefs.getString(MONITORING_OPT_EXIT_DISTANCE, null)?.toDoubleOrNull()
272
284
  minRssiThreshold = optPrefs.getInt(MONITORING_OPT_MIN_RSSI, DEFAULT_MIN_RSSI)
273
285
  eventLevel = optPrefs.getString(MONITORING_OPT_LEVEL, "all") ?: "all"
274
- exitTimeoutMs = ((optPrefs.getString(MONITORING_OPT_EXIT_TIMEOUT_SECONDS, null)?.toDoubleOrNull() ?: DEFAULT_EXIT_TIMEOUT_SECONDS) * 1000.0).toLong()
286
+ exitTimeoutMs =
287
+ ((optPrefs.getString(MONITORING_OPT_EXIT_TIMEOUT_SECONDS, null)?.toDoubleOrNull()
288
+ ?: DEFAULT_EXIT_TIMEOUT_SECONDS) * 1000.0).toLong()
275
289
  }
276
290
 
277
291
  private fun loadAndMonitorRegions() {
@@ -282,12 +296,18 @@ class BeaconForegroundService : Service(), BeaconConsumer {
282
296
  // Trailing-edge re-run so a debounced call is deferred, not dropped.
283
297
  if (!pendingLoadRegions) {
284
298
  pendingLoadRegions = true
285
- timeoutHandler.postDelayed({
286
- pendingLoadRegions = false
287
- loadAndMonitorRegions()
288
- }, LOAD_REGIONS_DEBOUNCE_MS - elapsed)
299
+ timeoutHandler.postDelayed(
300
+ {
301
+ pendingLoadRegions = false
302
+ loadAndMonitorRegions()
303
+ },
304
+ LOAD_REGIONS_DEBOUNCE_MS - elapsed
305
+ )
289
306
  }
290
- Log.d(TAG, "loadAndMonitorRegions: debounced (${elapsed}ms after last load) — re-running on trailing edge")
307
+ Log.d(
308
+ TAG,
309
+ "loadAndMonitorRegions: debounced (${elapsed}ms after last load) — re-running on trailing edge"
310
+ )
291
311
  return
292
312
  }
293
313
  lastLoadRegionsMs = now
@@ -295,12 +315,23 @@ class BeaconForegroundService : Service(), BeaconConsumer {
295
315
 
296
316
  val prefs: SharedPreferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
297
317
  val json = prefs.getString(PREFS_KEY, "[]") ?: "[]"
298
- val beacons = try { JSONArray(json) } catch (_: Exception) { JSONArray() }
318
+ val beacons =
319
+ try {
320
+ JSONArray(json)
321
+ } catch (_: Exception) {
322
+ JSONArray()
323
+ }
299
324
 
300
325
  // Load paired Eddystones
301
- val eddystonePrefs: SharedPreferences = getSharedPreferences(EDDYSTONE_PREFS_NAME, Context.MODE_PRIVATE)
326
+ val eddystonePrefs: SharedPreferences =
327
+ getSharedPreferences(EDDYSTONE_PREFS_NAME, Context.MODE_PRIVATE)
302
328
  val eddystoneJson = eddystonePrefs.getString(EDDYSTONE_PREFS_KEY, "[]") ?: "[]"
303
- val eddystones = try { JSONArray(eddystoneJson) } catch (_: Exception) { JSONArray() }
329
+ val eddystones =
330
+ try {
331
+ JSONArray(eddystoneJson)
332
+ } catch (_: Exception) {
333
+ JSONArray()
334
+ }
304
335
 
305
336
  // Build timeout lookup from paired beacon data
306
337
  beaconTimeouts.clear()
@@ -323,15 +354,18 @@ class BeaconForegroundService : Service(), BeaconConsumer {
323
354
 
324
355
  // Stop previous regions and distance-log ranging
325
356
  distanceLogRegions.forEach {
326
- try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
357
+ try {
358
+ beaconManager.stopRangingBeaconsInRegion(it)
359
+ } catch (_: RemoteException) {}
327
360
  }
328
361
  distanceLogRegions.clear()
329
362
  monitoredRegions.forEach {
330
- try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
363
+ try {
364
+ beaconManager.stopMonitoringBeaconsInRegion(it)
365
+ } catch (_: RemoteException) {}
331
366
  }
332
367
  monitoredRegions.clear()
333
368
  monitoredRegionIds.clear()
334
- lastSeenAtMs.clear()
335
369
  timeoutHandler.removeCallbacksAndMessages(null)
336
370
  timeoutRunnables.clear()
337
371
  inactivityRunnables.clear()
@@ -351,24 +385,37 @@ class BeaconForegroundService : Service(), BeaconConsumer {
351
385
  // iBeacon regions
352
386
  for (i in 0 until beacons.length()) {
353
387
  val b = beacons.getJSONObject(i)
354
- val region = Region(
355
- b.getString("identifier"),
356
- Identifier.parse(b.getString("uuid")),
357
- Identifier.fromInt(b.getInt("major")),
358
- Identifier.fromInt(b.getInt("minor"))
359
- )
388
+ val region =
389
+ Region(
390
+ b.getString("identifier"),
391
+ Identifier.parse(b.getString("uuid")),
392
+ Identifier.fromInt(b.getInt("major")),
393
+ Identifier.fromInt(b.getInt("minor"))
394
+ )
360
395
  monitoredRegions.add(region)
361
396
  monitoredRegionIds.add(region.uniqueId)
362
397
  try {
363
398
  beaconManager.startMonitoringBeaconsInRegion(region)
364
399
  } catch (e: RemoteException) {
365
400
  Log.e(TAG, "Failed to start monitoring iBeacon region ${region.uniqueId}", e)
366
- sendErrorBroadcast(region.uniqueId, "MONITORING_FAILED", "Failed to start monitoring iBeacon region ${region.uniqueId}")
401
+ sendErrorBroadcast(
402
+ region.uniqueId,
403
+ "MONITORING_FAILED",
404
+ "Failed to start monitoring iBeacon region ${region.uniqueId}"
405
+ )
367
406
  } catch (e: SecurityException) {
368
407
  // Android 17+ may throw SecurityException if BLUETOOTH_SCAN/CONNECT were
369
408
  // not held at the exact moment monitoring starts.
370
- Log.e(TAG, "Security exception starting monitoring for ${region.uniqueId} — check BT permissions", e)
371
- sendErrorBroadcast(region.uniqueId, "SECURITY_EXCEPTION", "Security exception starting monitoring for ${region.uniqueId} — check BT permissions")
409
+ Log.e(
410
+ TAG,
411
+ "Security exception starting monitoring for ${region.uniqueId} — check BT permissions",
412
+ e
413
+ )
414
+ sendErrorBroadcast(
415
+ region.uniqueId,
416
+ "SECURITY_EXCEPTION",
417
+ "Security exception starting monitoring for ${region.uniqueId} — check BT permissions"
418
+ )
372
419
  }
373
420
  // Start ranging this region for distance logging
374
421
  if (distanceLogRegions.add(region)) {
@@ -377,11 +424,23 @@ class BeaconForegroundService : Service(), BeaconConsumer {
377
424
  } catch (e: RemoteException) {
378
425
  distanceLogRegions.remove(region)
379
426
  Log.e(TAG, "Failed to start ranging iBeacon region ${region.uniqueId}", e)
380
- sendErrorBroadcast(region.uniqueId, "RANGING_FAILED", "Failed to start ranging iBeacon region ${region.uniqueId}")
427
+ sendErrorBroadcast(
428
+ region.uniqueId,
429
+ "RANGING_FAILED",
430
+ "Failed to start ranging iBeacon region ${region.uniqueId}"
431
+ )
381
432
  } catch (e: SecurityException) {
382
433
  distanceLogRegions.remove(region)
383
- Log.e(TAG, "Security exception starting ranging for ${region.uniqueId} — check BT permissions", e)
384
- sendErrorBroadcast(region.uniqueId, "SECURITY_EXCEPTION", "Security exception starting ranging for ${region.uniqueId} — check BT permissions")
434
+ Log.e(
435
+ TAG,
436
+ "Security exception starting ranging for ${region.uniqueId} — check BT permissions",
437
+ e
438
+ )
439
+ sendErrorBroadcast(
440
+ region.uniqueId,
441
+ "SECURITY_EXCEPTION",
442
+ "Security exception starting ranging for ${region.uniqueId} — check BT permissions"
443
+ )
385
444
  }
386
445
  }
387
446
  }
@@ -392,22 +451,35 @@ class BeaconForegroundService : Service(), BeaconConsumer {
392
451
  val identifier = e.getString("identifier")
393
452
  val namespace = e.getString("namespace")
394
453
  val instance = e.getString("instance")
395
- val region = Region(
396
- identifier,
397
- Identifier.parse("0x$namespace"),
398
- Identifier.parse("0x$instance"),
399
- null
400
- )
454
+ val region =
455
+ Region(
456
+ identifier,
457
+ Identifier.parse("0x$namespace"),
458
+ Identifier.parse("0x$instance"),
459
+ null
460
+ )
401
461
  monitoredRegions.add(region)
402
462
  monitoredRegionIds.add(region.uniqueId)
403
463
  try {
404
464
  beaconManager.startMonitoringBeaconsInRegion(region)
405
465
  } catch (ex: RemoteException) {
406
466
  Log.e(TAG, "Failed to start monitoring Eddystone region $identifier", ex)
407
- sendErrorBroadcast(identifier, "MONITORING_FAILED", "Failed to start monitoring Eddystone region $identifier")
467
+ sendErrorBroadcast(
468
+ identifier,
469
+ "MONITORING_FAILED",
470
+ "Failed to start monitoring Eddystone region $identifier"
471
+ )
408
472
  } catch (ex: SecurityException) {
409
- Log.e(TAG, "Security exception starting monitoring for Eddystone $identifier — check BT permissions", ex)
410
- sendErrorBroadcast(identifier, "SECURITY_EXCEPTION", "Security exception starting monitoring for Eddystone $identifier — check BT permissions")
473
+ Log.e(
474
+ TAG,
475
+ "Security exception starting monitoring for Eddystone $identifier — check BT permissions",
476
+ ex
477
+ )
478
+ sendErrorBroadcast(
479
+ identifier,
480
+ "SECURITY_EXCEPTION",
481
+ "Security exception starting monitoring for Eddystone $identifier — check BT permissions"
482
+ )
411
483
  }
412
484
  if (distanceLogRegions.add(region)) {
413
485
  try {
@@ -415,15 +487,29 @@ class BeaconForegroundService : Service(), BeaconConsumer {
415
487
  } catch (ex: RemoteException) {
416
488
  distanceLogRegions.remove(region)
417
489
  Log.e(TAG, "Failed to start ranging Eddystone region $identifier", ex)
418
- sendErrorBroadcast(identifier, "RANGING_FAILED", "Failed to start ranging Eddystone region $identifier")
490
+ sendErrorBroadcast(
491
+ identifier,
492
+ "RANGING_FAILED",
493
+ "Failed to start ranging Eddystone region $identifier"
494
+ )
419
495
  } catch (ex: SecurityException) {
420
496
  distanceLogRegions.remove(region)
421
- Log.e(TAG, "Security exception starting ranging for Eddystone $identifier — check BT permissions", ex)
422
- sendErrorBroadcast(identifier, "SECURITY_EXCEPTION", "Security exception starting ranging for Eddystone $identifier — check BT permissions")
497
+ Log.e(
498
+ TAG,
499
+ "Security exception starting ranging for Eddystone $identifier — check BT permissions",
500
+ ex
501
+ )
502
+ sendErrorBroadcast(
503
+ identifier,
504
+ "SECURITY_EXCEPTION",
505
+ "Security exception starting ranging for Eddystone $identifier — check BT permissions"
506
+ )
423
507
  }
424
508
  }
425
509
  }
426
510
 
511
+ lastSeenAtMs.keys.retainAll(monitoredRegionIds)
512
+
427
513
  // If no regions to monitor, stop the service to avoid idling
428
514
  if (monitoredRegions.isEmpty()) {
429
515
  enteredRegions.clear()
@@ -439,7 +525,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
439
525
  private val distanceLoggingRangeNotifier = RangeNotifier { beacons, region ->
440
526
  if (eventLevel != "all") return@RangeNotifier
441
527
  if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
442
- val closest = beacons.filter { it.distance >= 0 && it.rssi >= minRssiThreshold }.minByOrNull { it.distance }
528
+ val closest =
529
+ beacons.filter { it.distance >= 0 && it.rssi >= minRssiThreshold }.minByOrNull {
530
+ it.distance
531
+ }
443
532
  if (closest != null) {
444
533
  lastSeenAtMs[region.uniqueId] = SystemClock.elapsedRealtime()
445
534
  // Valid BLE reading — reset inactivity timer.
@@ -448,42 +537,48 @@ class BeaconForegroundService : Service(), BeaconConsumer {
448
537
  }
449
538
  }
450
539
 
451
- private val monitorNotifier = object : MonitorNotifier {
452
- override fun didEnterRegion(region: Region) {
453
- // Enter is synthesized from ranging so distance and enter/exit stay in sync.
454
- }
540
+ private val monitorNotifier =
541
+ object : MonitorNotifier {
542
+ override fun didEnterRegion(region: Region) {
543
+ // Enter is synthesized from ranging so distance and enter/exit stay in sync.
544
+ }
455
545
 
456
- override fun didExitRegion(region: Region) {
457
- if (!monitoredRegionIds.contains(region.uniqueId)) return
458
- if (wasSeenRecently(region.uniqueId)) {
459
- Log.d(TAG, "Ignoring stale didExitRegion for ${region.uniqueId}; beacon was seen by ranging recently")
460
- return
461
- }
546
+ override fun didExitRegion(region: Region) {
547
+ if (!monitoredRegionIds.contains(region.uniqueId)) return
548
+ if (wasSeenRecently(region.uniqueId)) {
549
+ Log.d(
550
+ TAG,
551
+ "Ignoring stale didExitRegion for ${region.uniqueId}; beacon was seen by ranging recently"
552
+ )
553
+ return
554
+ }
462
555
 
463
- lastSeenAtMs.remove(region.uniqueId)
556
+ lastSeenAtMs.remove(region.uniqueId)
464
557
 
465
- // Ranging-based hysteresis handles exit in the normal case. If the OS
466
- // fires didExitRegion after ranging has already stopped, emit exit as a
467
- // safety net only if the region was previously in the entered state.
468
- val wasEntered = enteredRegions.remove(region.uniqueId)
469
- synchronized(distanceLock) {
470
- enterCounters.remove(region.uniqueId)
471
- exitCounters.remove(region.uniqueId)
472
- missCounters.remove(region.uniqueId)
473
- }
474
- if (wasEntered) {
475
- sendBeaconBroadcast(region, "exit", -1.0)
476
- showEnterExitNotification(region, "exit")
477
- // OS-level exit safety net — cancel inactivity timer and start the timeout clock.
478
- cancelInactivity(region.uniqueId)
479
- scheduleTimeoutIfConfigured(region)
480
- }
481
- }
558
+ // Ranging-based hysteresis handles exit in the normal case. If the OS
559
+ // fires didExitRegion after ranging has already stopped, emit exit as a
560
+ // safety net only if the region was previously in the entered state.
561
+ val wasEntered = enteredRegions.remove(region.uniqueId)
562
+ synchronized(distanceLock) {
563
+ enterCounters.remove(region.uniqueId)
564
+ exitCounters.remove(region.uniqueId)
565
+ missCounters.remove(region.uniqueId)
566
+ }
567
+ if (wasEntered) {
568
+ synchronized(distanceLock) { smoothedDistances.remove(region.uniqueId) }
569
+ sendBeaconBroadcast(region, "exit", -1.0)
570
+ showEnterExitNotification(region, "exit")
571
+ // OS-level exit safety net — cancel inactivity timer and start the timeout
572
+ // clock.
573
+ cancelInactivity(region.uniqueId)
574
+ scheduleTimeoutIfConfigured(region)
575
+ }
576
+ }
482
577
 
483
- override fun didDetermineStateForRegion(state: Int, region: Region) {
484
- // Intentionally empty — enter/exit handled by didEnterRegion/didExitRegion.
485
- }
486
- }
578
+ override fun didDetermineStateForRegion(state: Int, region: Region) {
579
+ // Intentionally empty — enter/exit handled by didEnterRegion/didExitRegion.
580
+ }
581
+ }
487
582
 
488
583
  // Single source of truth for distance-based enter/exit with hysteresis.
489
584
  // Processes only actual monitoring regions and handles exit via miss counting
@@ -494,9 +589,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
494
589
  val maxDist = maxDistance
495
590
  if (!monitoredRegionIds.contains(region.uniqueId)) return@RangeNotifier
496
591
 
497
- val beacon = beacons
498
- .filter { it.distance >= 0 && it.rssi >= minRssiThreshold }
499
- .minByOrNull { it.distance }
592
+ val beacon =
593
+ beacons.filter { it.distance >= 0 && it.rssi >= minRssiThreshold }.minByOrNull {
594
+ it.distance
595
+ }
500
596
 
501
597
  // Pending transition to emit after the lock is released: (eventType, distance, rssi).
502
598
  var pendingEvent: Triple<String, Double, Int>? = null
@@ -518,6 +614,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
518
614
  }
519
615
  HysteresisAction.EXIT -> {
520
616
  enteredRegions.remove(region.uniqueId)
617
+ smoothedDistances.remove(region.uniqueId)
521
618
  pendingEvent = Triple("exit", beacon.distance, beacon.rssi)
522
619
  }
523
620
  HysteresisAction.NONE -> {}
@@ -528,7 +625,8 @@ class BeaconForegroundService : Service(), BeaconConsumer {
528
625
  // On Android 17+ (API 37) the BLE scan callbacks are more intermittent: valid
529
626
  // readings are interspersed with occasional null cycles even when the beacon is
530
627
  // nearby. Resetting direction counters on every null would prevent the hysteresis
531
- // from ever accumulating to ENTER_HYSTERESIS_COUNT, breaking enter/exit entirely while
628
+ // from ever accumulating to ENTER_HYSTERESIS_COUNT, breaking enter/exit entirely
629
+ // while
532
630
  // still allowing distance events (which fire on each individual valid reading).
533
631
  // Direction counters are reset by evaluateDistanceHysteresis when a valid reading
534
632
  // contradicts the current direction (e.g., in-range reading resets exitCounters).
@@ -536,12 +634,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
536
634
  missCounters[region.uniqueId] = count
537
635
 
538
636
  val lastSeen = lastSeenAtMs[region.uniqueId]
539
- val silentMs = if (lastSeen != null) SystemClock.elapsedRealtime() - lastSeen else Long.MAX_VALUE
637
+ val silentMs =
638
+ if (lastSeen != null) SystemClock.elapsedRealtime() - lastSeen
639
+ else Long.MAX_VALUE
540
640
  if (enteredRegions.contains(region.uniqueId) && silentMs >= exitTimeoutMs) {
541
641
  enteredRegions.remove(region.uniqueId)
542
642
  missCounters[region.uniqueId] = 0
543
643
  enterCounters[region.uniqueId] = 0
544
644
  exitCounters[region.uniqueId] = 0
645
+ smoothedDistances.remove(region.uniqueId)
545
646
  pendingEvent = Triple("exit", -1.0, 0)
546
647
  }
547
648
  }
@@ -563,14 +664,18 @@ class BeaconForegroundService : Service(), BeaconConsumer {
563
664
 
564
665
  // MARK: - Distance-based enter/exit hysteresis
565
666
 
566
- private enum class HysteresisAction { NONE, ENTER, EXIT }
667
+ private enum class HysteresisAction {
668
+ NONE,
669
+ ENTER,
670
+ EXIT
671
+ }
567
672
 
568
673
  /**
569
- * Apply exponential moving average (EMA) smoothing to a raw distance reading.
570
- * If the reading is a large jump (> DISTANCE_JUMP_FACTOR), resets the EMA to the new
571
- * value instead of rejecting it — this ensures the hysteresis pipeline keeps receiving
572
- * data and can fire exit events when the user moves away from a beacon, rather than
573
- * freezing because the EMA is stuck at the old close-range value.
674
+ * Apply exponential moving average (EMA) smoothing to a raw distance reading. If the reading is
675
+ * a large jump (> DISTANCE_JUMP_FACTOR), resets the EMA to the new value instead of rejecting
676
+ * it — this ensures the hysteresis pipeline keeps receiving data and can fire exit events when
677
+ * the user moves away from a beacon, rather than freezing because the EMA is stuck at the old
678
+ * close-range value.
574
679
  */
575
680
  private fun smoothDistance(regionId: String, rawDistance: Double): Double {
576
681
  val prev = smoothedDistances[regionId]
@@ -595,19 +700,20 @@ class BeaconForegroundService : Service(), BeaconConsumer {
595
700
  * Default: maxDistance + min(maxDistance × 0.5, 2.5).
596
701
  */
597
702
  private fun effectiveExitDistance(maxDist: Double): Double {
598
- exitDistance?.let { return it }
703
+ exitDistance?.let {
704
+ return it
705
+ }
599
706
  return maxDist + minOf(maxDist * 0.5, 2.5)
600
707
  }
601
708
 
602
709
  /**
603
- * Evaluate distance-based enter/exit with hysteresis counters.
604
- * Must be called within synchronized(distanceLock).
605
- * Mirrors [ExpoBeaconModule.swift evaluateDistanceHysteresis].
710
+ * Evaluate distance-based enter/exit with hysteresis counters. Must be called within
711
+ * synchronized(distanceLock). Mirrors [ExpoBeaconModule.swift evaluateDistanceHysteresis].
606
712
  */
607
713
  private fun evaluateDistanceHysteresis(
608
- regionId: String,
609
- distance: Double,
610
- maxDist: Double?
714
+ regionId: String,
715
+ distance: Double,
716
+ maxDist: Double?
611
717
  ): HysteresisAction {
612
718
  if (maxDist == null) {
613
719
  exitCounters[regionId] = 0
@@ -694,7 +800,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
694
800
  inactivityRunnables.remove(regionId)?.let { timeoutHandler.removeCallbacks(it) }
695
801
  }
696
802
 
697
- private fun sendBeaconBroadcast(region: Region, eventType: String, distance: Double, rssi: Int = 0) {
803
+ private fun sendBeaconBroadcast(
804
+ region: Region,
805
+ eventType: String,
806
+ distance: Double,
807
+ rssi: Int = 0
808
+ ) {
698
809
  // Determine if this is an Eddystone region based on identifier format
699
810
  // Eddystone regions have id1 as a hex namespace (not a UUID)
700
811
  val id1Str = region.id1?.toString() ?: ""
@@ -704,20 +815,21 @@ class BeaconForegroundService : Service(), BeaconConsumer {
704
815
  // Single payload shared by the SQLite log, the API forwarder, and the
705
816
  // JS broadcast. "event" is part of the public payload for enter/exit
706
817
  // only — distance and timeout omit it (matches the TS types and iOS).
707
- val params = buildMap<String, Any?> {
708
- put("identifier", region.uniqueId)
709
- if (isEddystone) {
710
- put("namespace", id1Str.removePrefix("0x"))
711
- put("instance", region.id2?.toString()?.removePrefix("0x") ?: "")
712
- } else {
713
- put("uuid", id1Str.uppercase())
714
- put("major", region.id2?.toInt() ?: 0)
715
- put("minor", region.id3?.toInt() ?: 0)
716
- }
717
- if (eventType == "enter" || eventType == "exit") put("event", eventType)
718
- put("distance", distance)
719
- put("rssi", rssi)
720
- }
818
+ val params =
819
+ buildMap<String, Any?> {
820
+ put("identifier", region.uniqueId)
821
+ if (isEddystone) {
822
+ put("namespace", id1Str.removePrefix("0x"))
823
+ put("instance", region.id2?.toString()?.removePrefix("0x") ?: "")
824
+ } else {
825
+ put("uuid", id1Str.uppercase())
826
+ put("major", region.id2?.toInt() ?: 0)
827
+ put("minor", region.id3?.toInt() ?: 0)
828
+ }
829
+ if (eventType == "enter" || eventType == "exit") put("event", eventType)
830
+ put("distance", distance)
831
+ put("rssi", rssi)
832
+ }
721
833
  logBeaconEvent(eventName, params)
722
834
 
723
835
  // Forward all produced events to remote API
@@ -732,9 +844,39 @@ class BeaconForegroundService : Service(), BeaconConsumer {
732
844
  val namespace = if (isEddystone) id1Str.removePrefix("0x") else ""
733
845
  val instance = if (isEddystone) region.id2?.toString()?.removePrefix("0x") ?: "" else ""
734
846
  when (eventType) {
735
- "enter" -> BeaconPluginRegistry.dispatchEnter(isEddystone, identifier, uuid, major, minor, namespace, instance, distance)
736
- "exit" -> BeaconPluginRegistry.dispatchExit(isEddystone, identifier, uuid, major, minor, namespace, instance, distance)
737
- "timeout" -> BeaconPluginRegistry.dispatchTimeout(isEddystone, identifier, uuid, major, minor, namespace, instance, distance)
847
+ "enter" ->
848
+ BeaconPluginRegistry.dispatchEnter(
849
+ isEddystone,
850
+ identifier,
851
+ uuid,
852
+ major,
853
+ minor,
854
+ namespace,
855
+ instance,
856
+ distance
857
+ )
858
+ "exit" ->
859
+ BeaconPluginRegistry.dispatchExit(
860
+ isEddystone,
861
+ identifier,
862
+ uuid,
863
+ major,
864
+ minor,
865
+ namespace,
866
+ instance,
867
+ distance
868
+ )
869
+ "timeout" ->
870
+ BeaconPluginRegistry.dispatchTimeout(
871
+ isEddystone,
872
+ identifier,
873
+ uuid,
874
+ major,
875
+ minor,
876
+ namespace,
877
+ instance,
878
+ distance
879
+ )
738
880
  }
739
881
  }
740
882
 
@@ -743,11 +885,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
743
885
  }
744
886
 
745
887
  private fun sendErrorBroadcast(identifier: String?, code: String, message: String) {
746
- val params = buildMap<String, Any?> {
747
- put("identifier", identifier ?: "")
748
- put("code", code)
749
- put("message", message)
750
- }
888
+ val params =
889
+ buildMap<String, Any?> {
890
+ put("identifier", identifier ?: "")
891
+ put("code", code)
892
+ put("message", message)
893
+ }
751
894
  logBeaconEvent("onBeaconError", params)
752
895
  sendEventBroadcast("onBeaconError", params)
753
896
  }
@@ -763,11 +906,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
763
906
  is Boolean -> bundle.putBoolean(key, value)
764
907
  }
765
908
  }
766
- val intent = Intent(ACTION_BEACON_EVENT).apply {
767
- putExtra(EXTRA_EVENT_NAME, eventName)
768
- putExtra(EXTRA_EVENT_PARAMS, bundle)
769
- setPackage(packageName)
770
- }
909
+ val intent =
910
+ Intent(ACTION_BEACON_EVENT).apply {
911
+ putExtra(EXTRA_EVENT_NAME, eventName)
912
+ putExtra(EXTRA_EVENT_PARAMS, bundle)
913
+ setPackage(packageName)
914
+ }
771
915
  sendBroadcast(intent)
772
916
  }
773
917
 
@@ -808,44 +952,53 @@ class BeaconForegroundService : Service(), BeaconConsumer {
808
952
  // Respect the enabled flag (defaults to true)
809
953
  if (eventsConfig != null && !eventsConfig.optBoolean("enabled", true)) return
810
954
 
811
- val defaultTitle = when (eventType) {
812
- "enter" -> "Beacon Entered"
813
- "timeout" -> "Beacon Timeout"
814
- else -> "Beacon Exited"
815
- }
816
- val title = when (eventType) {
817
- "enter" -> eventsConfig?.optString("enterTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
818
- "timeout" -> eventsConfig?.optString("timeoutTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
819
- else -> eventsConfig?.optString("exitTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
820
- }
955
+ val defaultTitle =
956
+ when (eventType) {
957
+ "enter" -> "Beacon Entered"
958
+ "timeout" -> "Beacon Timeout"
959
+ else -> "Beacon Exited"
960
+ }
961
+ val title =
962
+ when (eventType) {
963
+ "enter" -> eventsConfig?.optString("enterTitle")?.takeIf { it.isNotEmpty() }
964
+ ?: defaultTitle
965
+ "timeout" -> eventsConfig?.optString("timeoutTitle")?.takeIf { it.isNotEmpty() }
966
+ ?: defaultTitle
967
+ else -> eventsConfig?.optString("exitTitle")?.takeIf { it.isNotEmpty() }
968
+ ?: defaultTitle
969
+ }
821
970
 
822
- val bodyTemplate = eventsConfig?.optString("body")?.takeIf { it.isNotEmpty() }
823
- ?: "{identifier} region {event}ed"
824
- val message = bodyTemplate
825
- .replace("{identifier}", region.uniqueId)
826
- .replace("{event}", eventType)
971
+ val bodyTemplate =
972
+ eventsConfig?.optString("body")?.takeIf { it.isNotEmpty() }
973
+ ?: "{identifier} region {event}ed"
974
+ val message =
975
+ bodyTemplate.replace("{identifier}", region.uniqueId).replace("{event}", eventType)
827
976
 
828
- val notifId = notifIdMap.computeIfAbsent(region.uniqueId) {
829
- ENTER_EXIT_NOTIF_BASE_ID + notifIdCounter.getAndIncrement()
830
- }
977
+ val notifId =
978
+ notifIdMap.computeIfAbsent(region.uniqueId) {
979
+ ENTER_EXIT_NOTIF_BASE_ID + notifIdCounter.getAndIncrement()
980
+ }
831
981
  postEventNotification(CHANNEL_ID, eventsConfig, title, message, notifId)
832
982
  }
833
983
 
834
- /** Build and post a user-facing event notification; silently skipped without POST_NOTIFICATIONS. */
984
+ /**
985
+ * Build and post a user-facing event notification; silently skipped without POST_NOTIFICATIONS.
986
+ */
835
987
  private fun postEventNotification(
836
- channelId: String,
837
- eventsConfig: org.json.JSONObject?,
838
- title: String,
839
- message: String,
840
- notifId: Int
988
+ channelId: String,
989
+ eventsConfig: org.json.JSONObject?,
990
+ title: String,
991
+ message: String,
992
+ notifId: Int
841
993
  ) {
842
- val notification = NotificationCompat.Builder(this, channelId)
843
- .setSmallIcon(resolveIconRes(this, eventsConfig))
844
- .setContentTitle(title)
845
- .setContentText(message)
846
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
847
- .setAutoCancel(true)
848
- .build()
994
+ val notification =
995
+ NotificationCompat.Builder(this, channelId)
996
+ .setSmallIcon(resolveIconRes(this, eventsConfig))
997
+ .setContentTitle(title)
998
+ .setContentText(message)
999
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
1000
+ .setAutoCancel(true)
1001
+ .build()
849
1002
 
850
1003
  try {
851
1004
  NotificationManagerCompat.from(this).notify(notifId, notification)
@@ -857,21 +1010,21 @@ class BeaconForegroundService : Service(), BeaconConsumer {
857
1010
  // MARK: - CarPlay observer (service-hosted)
858
1011
 
859
1012
  /**
860
- * Lazily instantiate and start the CarPlay observer. Idempotent —
861
- * `CarPlayMonitor.start` itself is safe to call multiple times.
862
- * Must be called from any thread; the monitor hops to main internally.
1013
+ * Lazily instantiate and start the CarPlay observer. Idempotent — `CarPlayMonitor.start` itself
1014
+ * is safe to call multiple times. Must be called from any thread; the monitor hops to main
1015
+ * internally.
863
1016
  */
864
1017
  private fun startCarPlayObserverInternal() {
865
- val monitor = carPlayMonitor ?: try {
866
- CarPlayMonitor(applicationContext).also { carPlayMonitor = it }
867
- } catch (e: Throwable) {
868
- Log.w(TAG, "Failed to create CarPlayMonitor", e)
869
- return
870
- }
1018
+ val monitor =
1019
+ carPlayMonitor
1020
+ ?: try {
1021
+ CarPlayMonitor(applicationContext).also { carPlayMonitor = it }
1022
+ } catch (e: Throwable) {
1023
+ Log.w(TAG, "Failed to create CarPlayMonitor", e)
1024
+ return
1025
+ }
871
1026
  try {
872
- monitor.start { eventName, payload ->
873
- emitCarPlayEvent(eventName, payload)
874
- }
1027
+ monitor.start { eventName, payload -> emitCarPlayEvent(eventName, payload) }
875
1028
  } catch (e: Throwable) {
876
1029
  Log.w(TAG, "Failed to start CarPlayMonitor", e)
877
1030
  }
@@ -885,26 +1038,34 @@ class BeaconForegroundService : Service(), BeaconConsumer {
885
1038
  }
886
1039
 
887
1040
  /**
888
- * Re-emit CarPlay state to a freshly-bound module. Invoked from [bindModule]
889
- * whenever a new [ExpoBeaconModule] attaches while the foreground service is
890
- * already running (typical after the app process is killed and relaunched).
1041
+ * Re-emit CarPlay state to a freshly-bound module. Invoked from [bindModule] whenever a new
1042
+ * [ExpoBeaconModule] attaches while the foreground service is already running (typical after
1043
+ * the app process is killed and relaunched).
891
1044
  *
892
1045
  * Three outcomes:
893
- * 1. Observer running + car connected → re-emit `onCarPlayConnected` (JS-only;
1046
+ * 1. Observer running + car connected → re-emit `onCarPlayConnected` (JS-only;
1047
+ * ```
894
1048
  * SQLite / API / registry already ran when the event originally fired).
895
- * 2. Observer running + car disconnected + JS last knew "connected" →
1049
+ * ```
1050
+ * 2. Observer running + car disconnected + JS last knew "connected" →
1051
+ * ```
896
1052
  * emit synthetic `onCarPlayDisconnected` with `reason = "reconciled"`.
897
- * 3. Observer not yet running (fresh service start) → LiveData delivers
1053
+ * ```
1054
+ * 3. Observer not yet running (fresh service start) → LiveData delivers
1055
+ * ```
898
1056
  * the initial value naturally; no action needed.
1057
+ * ```
899
1058
  */
900
1059
  private fun reEmitCarPlayStateIfNeeded(module: ExpoBeaconModule) {
901
1060
  val monitor = carPlayMonitor ?: return
902
- if (!monitor.isObserving()) return // Fresh service start; LiveData handles first delivery.
1061
+ if (!monitor.isObserving()) return // Fresh service start; LiveData handles first delivery.
903
1062
 
904
1063
  val connPayload = monitor.buildConnectedPayload()
905
1064
  if (connPayload != null) {
906
1065
  // Car is still connected — give the new module the current state.
907
- try { module.forwardCarPlayEventFromService("onCarPlayConnected", connPayload) } catch (_: Throwable) {}
1066
+ try {
1067
+ module.forwardCarPlayEventFromService("onCarPlayConnected", connPayload)
1068
+ } catch (_: Throwable) {}
908
1069
  return
909
1070
  }
910
1071
 
@@ -912,12 +1073,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
912
1073
  // reconciled disconnect (mirrors iOS `reconcileOnProcessStart()`).
913
1074
  if (readJsConnected()) {
914
1075
  val now = System.currentTimeMillis()
915
- val payload = mapOf<String, Any?>(
916
- "timestamp" to now,
917
- "timestampIso" to buildIsoTimestamp(now),
918
- "reason" to "reconciled",
919
- )
920
- try { module.forwardCarPlayEventFromService("onCarPlayDisconnected", payload) } catch (_: Throwable) {}
1076
+ val payload =
1077
+ mapOf<String, Any?>(
1078
+ "timestamp" to now,
1079
+ "timestampIso" to buildIsoTimestamp(now),
1080
+ "reason" to "reconciled",
1081
+ )
1082
+ try {
1083
+ module.forwardCarPlayEventFromService("onCarPlayDisconnected", payload)
1084
+ } catch (_: Throwable) {}
921
1085
  writeJsConnected(false)
922
1086
  }
923
1087
  }
@@ -925,13 +1089,19 @@ class BeaconForegroundService : Service(), BeaconConsumer {
925
1089
  // MARK: - JS delivery state helpers
926
1090
 
927
1091
  private fun readJsConnected(): Boolean =
928
- try { getSharedPreferences(CARPLAY_JS_STATE_PREFS, Context.MODE_PRIVATE).getBoolean(CARPLAY_JS_STATE_KEY, false) }
929
- catch (_: Throwable) { false }
1092
+ try {
1093
+ getSharedPreferences(CARPLAY_JS_STATE_PREFS, Context.MODE_PRIVATE)
1094
+ .getBoolean(CARPLAY_JS_STATE_KEY, false)
1095
+ } catch (_: Throwable) {
1096
+ false
1097
+ }
930
1098
 
931
1099
  private fun writeJsConnected(connected: Boolean) {
932
1100
  try {
933
1101
  getSharedPreferences(CARPLAY_JS_STATE_PREFS, Context.MODE_PRIVATE)
934
- .edit().putBoolean(CARPLAY_JS_STATE_KEY, connected).apply()
1102
+ .edit()
1103
+ .putBoolean(CARPLAY_JS_STATE_KEY, connected)
1104
+ .apply()
935
1105
  } catch (_: Throwable) {}
936
1106
  }
937
1107
 
@@ -942,9 +1112,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
942
1112
  }
943
1113
 
944
1114
  /**
945
- * Fan out a CarPlay event to all sinks: SQLite log, remote API forwarder,
946
- * native plugin registry, and (best-effort) the live JS bridge. Runs from
947
- * the main thread (CarPlayMonitor's emit hop).
1115
+ * Fan out a CarPlay event to all sinks: SQLite log, remote API forwarder, native plugin
1116
+ * registry, and (best-effort) the live JS bridge. Runs from the main thread (CarPlayMonitor's
1117
+ * emit hop).
948
1118
  */
949
1119
  private fun emitCarPlayEvent(eventName: String, payload: Map<String, Any?>) {
950
1120
  // SQLite log (only if event logging is enabled).
@@ -957,12 +1127,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
957
1127
  Log.w(TAG, "CarPlay log write failed", e)
958
1128
  }
959
1129
  // Remote API forwarder (no-op if unconfigured).
960
- try { apiForwarder?.forwardEvent(payload, eventName) } catch (_: Throwable) {}
1130
+ try {
1131
+ apiForwarder?.forwardEvent(payload, eventName)
1132
+ } catch (_: Throwable) {}
961
1133
  // Native plugin registry (BeaconGeoPlugin etc.).
962
1134
  when (eventName) {
963
- "onCarPlayConnected" -> BeaconPluginRegistry.dispatchCarPlayConnected(
964
- payload["transport"] as? String ?: "unknown"
965
- )
1135
+ "onCarPlayConnected" ->
1136
+ BeaconPluginRegistry.dispatchCarPlayConnected(
1137
+ payload["transport"] as? String ?: "unknown"
1138
+ )
966
1139
  "onCarPlayDisconnected" -> BeaconPluginRegistry.dispatchCarPlayDisconnected()
967
1140
  }
968
1141
  // Best-effort delivery to the live JS bridge if a module instance is bound.
@@ -975,10 +1148,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
975
1148
  // Local notification for connect/disconnect (config-gated).
976
1149
  try {
977
1150
  when (eventName) {
978
- "onCarPlayConnected" -> showCarPlayNotification(
979
- "connected",
980
- payload["transport"] as? String,
981
- )
1151
+ "onCarPlayConnected" ->
1152
+ showCarPlayNotification(
1153
+ "connected",
1154
+ payload["transport"] as? String,
1155
+ )
982
1156
  "onCarPlayDisconnected" -> showCarPlayNotification("disconnected", null)
983
1157
  }
984
1158
  } catch (e: Throwable) {
@@ -993,20 +1167,26 @@ class BeaconForegroundService : Service(), BeaconConsumer {
993
1167
  // Respect the enabled flag (defaults to true)
994
1168
  if (eventsConfig != null && !eventsConfig.optBoolean("enabled", true)) return
995
1169
 
996
- val defaultTitle = if (eventType == "connected") "CarPlay Connected" else "CarPlay Disconnected"
997
- val title = when (eventType) {
998
- "connected" -> eventsConfig?.optString("connectedTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
999
- else -> eventsConfig?.optString("disconnectedTitle")?.takeIf { it.isNotEmpty() } ?: defaultTitle
1000
- }
1170
+ val defaultTitle =
1171
+ if (eventType == "connected") "CarPlay Connected" else "CarPlay Disconnected"
1172
+ val title =
1173
+ when (eventType) {
1174
+ "connected" ->
1175
+ eventsConfig?.optString("connectedTitle")?.takeIf { it.isNotEmpty() }
1176
+ ?: defaultTitle
1177
+ else -> eventsConfig?.optString("disconnectedTitle")?.takeIf { it.isNotEmpty() }
1178
+ ?: defaultTitle
1179
+ }
1001
1180
 
1002
- val bodyTemplate = eventsConfig?.optString("body")?.takeIf { it.isNotEmpty() }
1003
- ?: "CarPlay session {event}"
1004
- val message = bodyTemplate
1005
- .replace("{event}", eventType)
1006
- .replace("{transport}", transport ?: "")
1181
+ val bodyTemplate =
1182
+ eventsConfig?.optString("body")?.takeIf { it.isNotEmpty() }
1183
+ ?: "CarPlay session {event}"
1184
+ val message =
1185
+ bodyTemplate.replace("{event}", eventType).replace("{transport}", transport ?: "")
1007
1186
 
1008
- val notifId = if (eventType == "connected") CARPLAY_CONNECTED_NOTIF_ID
1009
- else CARPLAY_DISCONNECTED_NOTIF_ID
1187
+ val notifId =
1188
+ if (eventType == "connected") CARPLAY_CONNECTED_NOTIF_ID
1189
+ else CARPLAY_DISCONNECTED_NOTIF_ID
1010
1190
  postEventNotification(CARPLAY_CHANNEL_ID, eventsConfig, title, message, notifId)
1011
1191
  }
1012
1192
 
@@ -1019,8 +1199,8 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1019
1199
  val regionIds = monitoredRegionIds.toList()
1020
1200
  return regionIds.associateWith { regionId ->
1021
1201
  MonitoringRuntimeState(
1022
- isEntered = enteredRegions.contains(regionId),
1023
- distance = smoothedDistances[regionId]
1202
+ isEntered = enteredRegions.contains(regionId),
1203
+ distance = smoothedDistances[regionId]
1024
1204
  )
1025
1205
  }
1026
1206
  }
@@ -1035,9 +1215,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1035
1215
  private const val EXTRA_RETRY_COUNT = "retryCount"
1036
1216
  /** Intent action: enable CarPlay/Android Auto observation in the foreground service. */
1037
1217
  const val ACTION_ENABLE_CARPLAY = "expo.modules.beacon.ENABLE_CARPLAY"
1038
- /** Intent action: disable CarPlay/Android Auto observation. Stops the service if no other reason to run. */
1218
+ /**
1219
+ * Intent action: disable CarPlay/Android Auto observation. Stops the service if no other
1220
+ * reason to run.
1221
+ */
1039
1222
  const val ACTION_DISABLE_CARPLAY = "expo.modules.beacon.DISABLE_CARPLAY"
1040
- /** Intent action: stop beacon ranging/monitoring while keeping the service alive for CarPlay. */
1223
+ /**
1224
+ * Intent action: stop beacon ranging/monitoring while keeping the service alive for
1225
+ * CarPlay.
1226
+ */
1041
1227
  const val ACTION_DISABLE_MONITORING = "expo.modules.beacon.DISABLE_MONITORING"
1042
1228
  private const val MAX_STARTFOREGROUND_RETRIES = 3
1043
1229
  private const val RETRY_DELAY_MS = 10_000L
@@ -1050,7 +1236,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1050
1236
 
1051
1237
  fun start(context: Context) {
1052
1238
  context.getSharedPreferences(MONITORING_ACTIVE_PREFS, Context.MODE_PRIVATE)
1053
- .edit().putBoolean(MONITORING_ACTIVE_KEY, true).apply()
1239
+ .edit()
1240
+ .putBoolean(MONITORING_ACTIVE_KEY, true)
1241
+ .apply()
1054
1242
  ensureNotificationChannel(context)
1055
1243
  ensureCarPlayNotificationChannel(context)
1056
1244
  val intent = Intent(context, BeaconForegroundService::class.java)
@@ -1063,7 +1251,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1063
1251
 
1064
1252
  fun stop(context: Context) {
1065
1253
  context.getSharedPreferences(MONITORING_ACTIVE_PREFS, Context.MODE_PRIVATE)
1066
- .edit().putBoolean(MONITORING_ACTIVE_KEY, false).apply()
1254
+ .edit()
1255
+ .putBoolean(MONITORING_ACTIVE_KEY, false)
1256
+ .apply()
1067
1257
  // Keep the service alive if it's still needed for CarPlay observation;
1068
1258
  // otherwise the user would silently lose CarPlay events when calling
1069
1259
  // stopMonitoring() while CarPlay monitoring was independently enabled.
@@ -1073,8 +1263,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1073
1263
  // future cold start in CarPlay-only mode.
1074
1264
  if (isCarPlayEnabled(context)) {
1075
1265
  if (activeService != null) {
1076
- val intent = Intent(context, BeaconForegroundService::class.java)
1077
- .setAction(ACTION_DISABLE_MONITORING)
1266
+ val intent =
1267
+ Intent(context, BeaconForegroundService::class.java)
1268
+ .setAction(ACTION_DISABLE_MONITORING)
1078
1269
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1079
1270
  context.startForegroundService(intent)
1080
1271
  } else {
@@ -1088,30 +1279,31 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1088
1279
 
1089
1280
  fun isMonitoringActive(context: Context): Boolean {
1090
1281
  return context.getSharedPreferences(MONITORING_ACTIVE_PREFS, Context.MODE_PRIVATE)
1091
- .getBoolean(MONITORING_ACTIVE_KEY, false)
1282
+ .getBoolean(MONITORING_ACTIVE_KEY, false)
1092
1283
  }
1093
1284
 
1094
1285
  // MARK: - CarPlay public API
1095
1286
 
1096
1287
  /**
1097
- * Persist whether the user wants CarPlay observation. Cold service starts
1098
- * (e.g. via [BootReceiver]) read this flag in [onCreate] and re-attach
1099
- * the observer automatically.
1288
+ * Persist whether the user wants CarPlay observation. Cold service starts (e.g. via
1289
+ * [BootReceiver]) read this flag in [onCreate] and re-attach the observer automatically.
1100
1290
  */
1101
1291
  internal fun setCarPlayEnabled(context: Context, enabled: Boolean) {
1102
1292
  context.getSharedPreferences(CARPLAY_ENABLED_PREFS, Context.MODE_PRIVATE)
1103
- .edit().putBoolean(CARPLAY_ENABLED_KEY, enabled).apply()
1293
+ .edit()
1294
+ .putBoolean(CARPLAY_ENABLED_KEY, enabled)
1295
+ .apply()
1104
1296
  }
1105
1297
 
1106
1298
  fun isCarPlayEnabled(context: Context): Boolean {
1107
1299
  return context.getSharedPreferences(CARPLAY_ENABLED_PREFS, Context.MODE_PRIVATE)
1108
- .getBoolean(CARPLAY_ENABLED_KEY, false)
1300
+ .getBoolean(CARPLAY_ENABLED_KEY, false)
1109
1301
  }
1110
1302
 
1111
1303
  /**
1112
- * Enable CarPlay observation. Starts the foreground service if it's not
1113
- * already running so the observer survives app suspension and process death.
1114
- * Idempotent and safe to call from any context.
1304
+ * Enable CarPlay observation. Starts the foreground service if it's not already running so
1305
+ * the observer survives app suspension and process death. Idempotent and safe to call from
1306
+ * any context.
1115
1307
  */
1116
1308
  fun enableCarPlay(context: Context) {
1117
1309
  setCarPlayEnabled(context, true)
@@ -1123,8 +1315,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1123
1315
  // is killed while CarPlay observation is enabled.
1124
1316
  CarPlayWatchdogWorker.schedule(context)
1125
1317
  BootReceiver.scheduleCarPlayWatchdogAlarm(context)
1126
- val intent = Intent(context, BeaconForegroundService::class.java)
1127
- .setAction(ACTION_ENABLE_CARPLAY)
1318
+ val intent =
1319
+ Intent(context, BeaconForegroundService::class.java)
1320
+ .setAction(ACTION_ENABLE_CARPLAY)
1128
1321
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1129
1322
  context.startForegroundService(intent)
1130
1323
  } else {
@@ -1133,8 +1326,8 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1133
1326
  }
1134
1327
 
1135
1328
  /**
1136
- * Disable CarPlay observation. The foreground service stops itself
1137
- * if no other monitoring reason remains.
1329
+ * Disable CarPlay observation. The foreground service stops itself if no other monitoring
1330
+ * reason remains.
1138
1331
  */
1139
1332
  fun disableCarPlay(context: Context) {
1140
1333
  setCarPlayEnabled(context, false)
@@ -1149,10 +1342,13 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1149
1342
  // Clear the JS delivery state so a future re-enable starts from a clean slate.
1150
1343
  try {
1151
1344
  context.getSharedPreferences(CARPLAY_JS_STATE_PREFS, Context.MODE_PRIVATE)
1152
- .edit().clear().apply()
1345
+ .edit()
1346
+ .clear()
1347
+ .apply()
1153
1348
  } catch (_: Throwable) {}
1154
- val intent = Intent(context, BeaconForegroundService::class.java)
1155
- .setAction(ACTION_DISABLE_CARPLAY)
1349
+ val intent =
1350
+ Intent(context, BeaconForegroundService::class.java)
1351
+ .setAction(ACTION_DISABLE_CARPLAY)
1156
1352
  // Best-effort: if the service isn't running, sending the intent will
1157
1353
  // start it just to stop it. Skip startForegroundService unless beacon
1158
1354
  // monitoring is also active (otherwise the start might fail with
@@ -1167,9 +1363,8 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1167
1363
  }
1168
1364
 
1169
1365
  /**
1170
- * Bind a live module instance for best-effort JS-bridge delivery of
1171
- * service-emitted events. The reference is weak; pass `null` from the
1172
- * module's OnDestroy to clear it.
1366
+ * Bind a live module instance for best-effort JS-bridge delivery of service-emitted events.
1367
+ * The reference is weak; pass `null` from the module's OnDestroy to clear it.
1173
1368
  */
1174
1369
  fun bindModule(module: ExpoBeaconModule?) {
1175
1370
  boundModule = module?.let { java.lang.ref.WeakReference(it) }
@@ -1184,9 +1379,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1184
1379
  }
1185
1380
 
1186
1381
  /**
1187
- * Returns a snapshot of the current CarPlay / Android Auto connection state.
1188
- * Reads from the live [CarPlayMonitor] if the foreground service is running,
1189
- * otherwise falls back to the persisted last-known state.
1382
+ * Returns a snapshot of the current CarPlay / Android Auto connection state. Reads from the
1383
+ * live [CarPlayMonitor] if the foreground service is running, otherwise falls back to the
1384
+ * persisted last-known state.
1190
1385
  */
1191
1386
  fun getCarPlayStatus(context: Context): Map<String, Any?> {
1192
1387
  val monitor = activeService?.carPlayMonitor
@@ -1198,132 +1393,166 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1198
1393
  return mapOf("connected" to false)
1199
1394
  }
1200
1395
  // Fallback: persisted last-known state (valid even when service is stopped).
1201
- val connected = try {
1202
- context.getSharedPreferences(CarPlayMonitor.CARPLAY_MONITOR_PREFS, Context.MODE_PRIVATE)
1203
- .getBoolean(CarPlayMonitor.KEY_LAST_CONNECTED, false)
1204
- } catch (_: Throwable) { false }
1396
+ val connected =
1397
+ try {
1398
+ context.getSharedPreferences(
1399
+ CarPlayMonitor.CARPLAY_MONITOR_PREFS,
1400
+ Context.MODE_PRIVATE
1401
+ )
1402
+ .getBoolean(CarPlayMonitor.KEY_LAST_CONNECTED, false)
1403
+ } catch (_: Throwable) {
1404
+ false
1405
+ }
1205
1406
  return mapOf("connected" to connected)
1206
1407
  }
1207
1408
 
1208
1409
  /**
1209
- * Returns a diagnostic snapshot of the CarPlay / Android Auto detection
1210
- * pipeline. Probes for the host-app AA registration meta-data and the
1211
- * resolvability of the CarConnection content provider so callers can
1212
- * tell from JS whether the underlying detection requirements are met.
1410
+ * Returns a diagnostic snapshot of the CarPlay / Android Auto detection pipeline. Probes
1411
+ * for the host-app AA registration meta-data and the resolvability of the CarConnection
1412
+ * content provider so callers can tell from JS whether the underlying detection
1413
+ * requirements are met.
1213
1414
  */
1214
1415
  fun getCarPlayDiagnostics(context: Context): Map<String, Any?> {
1215
1416
  val pm = context.packageManager
1216
1417
  // 1) Is the host app declared as an Android-Auto-aware app?
1217
- val metadataPresent = try {
1218
- val ai = pm.getApplicationInfo(
1219
- context.packageName,
1220
- android.content.pm.PackageManager.GET_META_DATA,
1221
- )
1222
- ai.metaData?.containsKey("com.google.android.gms.car.application") == true
1223
- } catch (_: Throwable) { false }
1418
+ val metadataPresent =
1419
+ try {
1420
+ val ai =
1421
+ pm.getApplicationInfo(
1422
+ context.packageName,
1423
+ android.content.pm.PackageManager.GET_META_DATA,
1424
+ )
1425
+ ai.metaData?.containsKey("com.google.android.gms.car.application") == true
1426
+ } catch (_: Throwable) {
1427
+ false
1428
+ }
1224
1429
 
1225
1430
  // 2) Can the system resolve any provider for the CAR_PROVIDER action?
1226
1431
  // This combines (a) <queries> visibility on API 30+, and (b) a
1227
1432
  // Gearhead / AAOS install that actually advertises the provider.
1228
- val providerQueryable = try {
1229
- val intent = Intent("androidx.car.app.connection.action.CAR_PROVIDER")
1230
- val resolved = pm.queryIntentContentProviders(intent, 0)
1231
- resolved.isNotEmpty()
1232
- } catch (_: Throwable) { false }
1433
+ val providerQueryable =
1434
+ try {
1435
+ val intent = Intent("androidx.car.app.connection.action.CAR_PROVIDER")
1436
+ val resolved = pm.queryIntentContentProviders(intent, 0)
1437
+ resolved.isNotEmpty()
1438
+ } catch (_: Throwable) {
1439
+ false
1440
+ }
1233
1441
 
1234
1442
  val monitor = activeService?.carPlayMonitor
1235
- val lastRaw: Int? = if (monitor != null && monitor.hasObservedValue) monitor.lastObservedType else null
1443
+ val lastRaw: Int? =
1444
+ if (monitor != null && monitor.hasObservedValue) monitor.lastObservedType
1445
+ else null
1236
1446
  val observerActive = monitor?.isObserving() == true
1237
1447
  val serviceAlive = activeService != null
1238
1448
 
1239
1449
  return mapOf(
1240
- "isCarAppMetadataPresent" to metadataPresent,
1241
- "isCarProviderQueryable" to providerQueryable,
1242
- "lastRawConnectionType" to lastRaw,
1243
- "observerActive" to observerActive,
1244
- "serviceAlive" to serviceAlive,
1450
+ "isCarAppMetadataPresent" to metadataPresent,
1451
+ "isCarProviderQueryable" to providerQueryable,
1452
+ "lastRawConnectionType" to lastRaw,
1453
+ "observerActive" to observerActive,
1454
+ "serviceAlive" to serviceAlive,
1245
1455
  )
1246
1456
  }
1247
1457
 
1248
1458
  /** Read the persisted notification config JSON; empty object when unset or malformed. */
1249
1459
  internal fun readNotificationConfig(context: Context): org.json.JSONObject {
1250
- val json = context.getSharedPreferences(NOTIFICATION_CONFIG_PREFS, Context.MODE_PRIVATE)
1251
- .getString(NOTIFICATION_CONFIG_KEY, null) ?: return org.json.JSONObject()
1252
- return try { org.json.JSONObject(json) } catch (_: Exception) { org.json.JSONObject() }
1460
+ val json =
1461
+ context.getSharedPreferences(NOTIFICATION_CONFIG_PREFS, Context.MODE_PRIVATE)
1462
+ .getString(NOTIFICATION_CONFIG_KEY, null)
1463
+ ?: return org.json.JSONObject()
1464
+ return try {
1465
+ org.json.JSONObject(json)
1466
+ } catch (_: Exception) {
1467
+ org.json.JSONObject()
1468
+ }
1253
1469
  }
1254
1470
 
1255
- /** Resolve the configured small-icon drawable from [config], falling back to a stock icon. */
1471
+ /**
1472
+ * Resolve the configured small-icon drawable from [config], falling back to a stock icon.
1473
+ */
1256
1474
  private fun resolveIconRes(context: Context, config: org.json.JSONObject?): Int {
1257
1475
  val iconName = config?.optString("icon")?.takeIf { it.isNotEmpty() }
1258
1476
  return iconName?.let { name ->
1259
- try { context.resources.getIdentifier(name, "drawable", context.packageName).takeIf { it != 0 } }
1260
- catch (_: Exception) { null }
1261
- } ?: android.R.drawable.ic_dialog_info
1477
+ try {
1478
+ context.resources.getIdentifier(name, "drawable", context.packageName).takeIf {
1479
+ it != 0
1480
+ }
1481
+ } catch (_: Exception) {
1482
+ null
1483
+ }
1484
+ }
1485
+ ?: android.R.drawable.ic_dialog_info
1262
1486
  }
1263
1487
 
1264
1488
  private fun ensureChannel(
1265
- context: Context,
1266
- channelId: String,
1267
- configKey: String,
1268
- defaultName: String,
1269
- defaultDescription: String,
1270
- fallbackImportance: Int
1489
+ context: Context,
1490
+ channelId: String,
1491
+ configKey: String,
1492
+ defaultName: String,
1493
+ defaultDescription: String,
1494
+ fallbackImportance: Int
1271
1495
  ) {
1272
1496
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
1273
1497
  val channelConfig = readNotificationConfig(context).optJSONObject(configKey)
1274
1498
 
1275
- val channelName = channelConfig?.optString("name")?.takeIf { it.isNotEmpty() }
1276
- ?: defaultName
1277
- val channelDesc = channelConfig?.optString("description")?.takeIf { it.isNotEmpty() }
1278
- ?: defaultDescription
1279
- val importance = when (channelConfig?.optString("importance")) {
1280
- "high" -> NotificationManager.IMPORTANCE_HIGH
1281
- "default" -> NotificationManager.IMPORTANCE_DEFAULT
1282
- "low" -> NotificationManager.IMPORTANCE_LOW
1283
- else -> fallbackImportance
1284
- }
1499
+ val channelName =
1500
+ channelConfig?.optString("name")?.takeIf { it.isNotEmpty() } ?: defaultName
1501
+ val channelDesc =
1502
+ channelConfig?.optString("description")?.takeIf { it.isNotEmpty() }
1503
+ ?: defaultDescription
1504
+ val importance =
1505
+ when (channelConfig?.optString("importance")) {
1506
+ "high" -> NotificationManager.IMPORTANCE_HIGH
1507
+ "default" -> NotificationManager.IMPORTANCE_DEFAULT
1508
+ "low" -> NotificationManager.IMPORTANCE_LOW
1509
+ else -> fallbackImportance
1510
+ }
1285
1511
 
1286
1512
  val notifMgr = context.getSystemService(NotificationManager::class.java)
1287
1513
  // Only create channel if it doesn't exist yet — preserves user notification preferences
1288
1514
  if (notifMgr?.getNotificationChannel(channelId) == null) {
1289
- val channel = NotificationChannel(channelId, channelName, importance).apply {
1290
- description = channelDesc
1291
- }
1515
+ val channel =
1516
+ NotificationChannel(channelId, channelName, importance).apply {
1517
+ description = channelDesc
1518
+ }
1292
1519
  notifMgr?.createNotificationChannel(channel)
1293
1520
  }
1294
1521
  }
1295
1522
 
1296
1523
  /**
1297
- * Ensure the notification channel exists. Must be called before building
1298
- * a notification from a non-service context (e.g. ExpoBeaconModule).
1524
+ * Ensure the notification channel exists. Must be called before building a notification
1525
+ * from a non-service context (e.g. ExpoBeaconModule).
1299
1526
  */
1300
- fun ensureNotificationChannel(context: Context) = ensureChannel(
1301
- context,
1302
- CHANNEL_ID,
1303
- "channel",
1304
- "Beacon Monitoring",
1305
- "Used for background iBeacon region monitoring",
1306
- NotificationManager.IMPORTANCE_LOW
1307
- )
1527
+ fun ensureNotificationChannel(context: Context) =
1528
+ ensureChannel(
1529
+ context,
1530
+ CHANNEL_ID,
1531
+ "channel",
1532
+ "Beacon Monitoring",
1533
+ "Used for background iBeacon region monitoring",
1534
+ NotificationManager.IMPORTANCE_LOW
1535
+ )
1308
1536
 
1309
1537
  /**
1310
- * Ensure the CarPlay notification channel exists. Mirrors
1311
- * [ensureNotificationChannel] for the dedicated CarPlay channel so that
1312
- * users can mute CarPlay notifications independently in system settings.
1538
+ * Ensure the CarPlay notification channel exists. Mirrors [ensureNotificationChannel] for
1539
+ * the dedicated CarPlay channel so that users can mute CarPlay notifications independently
1540
+ * in system settings.
1313
1541
  */
1314
- fun ensureCarPlayNotificationChannel(context: Context) = ensureChannel(
1315
- context,
1316
- CARPLAY_CHANNEL_ID,
1317
- "carPlayChannel",
1318
- "CarPlay / Android Auto",
1319
- "CarPlay and Android Auto connect/disconnect notifications",
1320
- NotificationManager.IMPORTANCE_DEFAULT
1321
- )
1542
+ fun ensureCarPlayNotificationChannel(context: Context) =
1543
+ ensureChannel(
1544
+ context,
1545
+ CARPLAY_CHANNEL_ID,
1546
+ "carPlayChannel",
1547
+ "CarPlay / Android Auto",
1548
+ "CarPlay and Android Auto connect/disconnect notifications",
1549
+ NotificationManager.IMPORTANCE_DEFAULT
1550
+ )
1322
1551
 
1323
1552
  /**
1324
- * Build the persistent foreground-service notification from any Context.
1325
- * Static so cold-start paths that run before a service instance exists
1326
- * (e.g. [BootReceiver]) read the same persisted config.
1553
+ * Build the persistent foreground-service notification from any Context. Static so
1554
+ * cold-start paths that run before a service instance exists (e.g. [BootReceiver]) read the
1555
+ * same persisted config.
1327
1556
  */
1328
1557
  fun buildForegroundNotification(context: Context): Notification {
1329
1558
  val fgConfig = readNotificationConfig(context).optJSONObject("foregroundService")
@@ -1333,29 +1562,32 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1333
1562
  // users don't see "Monitoring for iBeacons" when no beacons are being
1334
1563
  // monitored.
1335
1564
  val carPlayOnly = !isMonitoringActive(context) && isCarPlayEnabled(context)
1336
- val defaultTitle = if (carPlayOnly) "Connected device monitoring active" else "Beacon Monitoring Active"
1337
- val defaultText = if (carPlayOnly) "Monitoring connected vehicle (CarPlay/Android Auto)" else "Monitoring for iBeacons in the background"
1565
+ val defaultTitle =
1566
+ if (carPlayOnly) "Connected device monitoring active"
1567
+ else "Beacon Monitoring Active"
1568
+ val defaultText =
1569
+ if (carPlayOnly) "Monitoring connected vehicle (CarPlay/Android Auto)"
1570
+ else "Monitoring for iBeacons in the background"
1338
1571
 
1339
1572
  val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() } ?: defaultTitle
1340
1573
  val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() } ?: defaultText
1341
1574
 
1342
1575
  return NotificationCompat.Builder(context, CHANNEL_ID)
1343
- .setSmallIcon(resolveIconRes(context, fgConfig))
1344
- .setContentTitle(title)
1345
- .setContentText(text)
1346
- .setPriority(NotificationCompat.PRIORITY_LOW)
1347
- .setOngoing(true)
1348
- .build()
1576
+ .setSmallIcon(resolveIconRes(context, fgConfig))
1577
+ .setContentTitle(title)
1578
+ .setContentText(text)
1579
+ .setPriority(NotificationCompat.PRIORITY_LOW)
1580
+ .setOngoing(true)
1581
+ .build()
1349
1582
  }
1350
1583
  }
1351
1584
 
1352
1585
  private fun readNotificationConfig(): org.json.JSONObject = readNotificationConfig(this)
1353
1586
 
1354
1587
  /**
1355
- * Stop all beacon ranging/monitoring, cancel beacon timers, and unbind from
1356
- * AltBeacon, while leaving the service (and any CarPlay observer) running.
1357
- * Safe to call when monitoring was never armed. Used both by
1358
- * [ACTION_DISABLE_MONITORING] (CarPlay-only mode) and [onDestroy].
1588
+ * Stop all beacon ranging/monitoring, cancel beacon timers, and unbind from AltBeacon, while
1589
+ * leaving the service (and any CarPlay observer) running. Safe to call when monitoring was
1590
+ * never armed. Used both by [ACTION_DISABLE_MONITORING] (CarPlay-only mode) and [onDestroy].
1359
1591
  */
1360
1592
  private fun disableMonitoringInternal() {
1361
1593
  pendingLoadRegions = false
@@ -1365,11 +1597,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1365
1597
  beaconTimeouts.clear()
1366
1598
  lastSeenAtMs.clear()
1367
1599
  distanceLogRegions.forEach {
1368
- try { beaconManager.stopRangingBeaconsInRegion(it) } catch (_: RemoteException) {}
1600
+ try {
1601
+ beaconManager.stopRangingBeaconsInRegion(it)
1602
+ } catch (_: RemoteException) {}
1369
1603
  }
1370
1604
  distanceLogRegions.clear()
1371
1605
  monitoredRegions.forEach {
1372
- try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
1606
+ try {
1607
+ beaconManager.stopMonitoringBeaconsInRegion(it)
1608
+ } catch (_: RemoteException) {}
1373
1609
  }
1374
1610
  monitoredRegions.clear()
1375
1611
  monitoredRegionIds.clear()
@@ -1387,7 +1623,9 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1387
1623
  // instances skip beaconManager.bind() in onStartCommand.
1388
1624
  if (serviceConnected) {
1389
1625
  serviceConnected = false
1390
- try { beaconManager.unbind(this) } catch (_: Throwable) {}
1626
+ try {
1627
+ beaconManager.unbind(this)
1628
+ } catch (_: Throwable) {}
1391
1629
  }
1392
1630
  }
1393
1631
 
@@ -1412,17 +1650,15 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1412
1650
  override fun onBind(intent: Intent?): IBinder? = null
1413
1651
 
1414
1652
  /**
1415
- * Defensive override: when the user swipes the host app away from
1416
- * Recents, Android delivers `onTaskRemoved` to bound services. Our
1417
- * service is started via `startForegroundService` with `START_STICKY`
1418
- * and is intended to keep running independently of the app's task
1419
- * specifically so that CarPlay / Android Auto observation and beacon
1420
- * monitoring continue across swipe-away. We intentionally do NOT call
1421
- * `stopSelf()` here; the system will redeliver `onStartCommand` on
1653
+ * Defensive override: when the user swipes the host app away from Recents, Android delivers
1654
+ * `onTaskRemoved` to bound services. Our service is started via `startForegroundService` with
1655
+ * `START_STICKY` and is intended to keep running independently of the app's task — specifically
1656
+ * so that CarPlay / Android Auto observation and beacon monitoring continue across swipe-away.
1657
+ * We intentionally do NOT call `stopSelf()` here; the system will redeliver `onStartCommand` on
1422
1658
  * its own if the process is later reclaimed.
1423
1659
  *
1424
- * We also arm a near-term keepalive alarm so devices that tear down the
1425
- * process on task removal recover before the slower periodic watchdogs run.
1660
+ * We also arm a near-term keepalive alarm so devices that tear down the process on task removal
1661
+ * recover before the slower periodic watchdogs run.
1426
1662
  */
1427
1663
  override fun onTaskRemoved(rootIntent: Intent?) {
1428
1664
  val keepAlive = isMonitoringActive(this) || isCarPlayEnabled(this)
@@ -1439,10 +1675,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1439
1675
  }
1440
1676
  }
1441
1677
  Log.d(
1442
- TAG,
1443
- "onTaskRemoved received (monitoring=${isMonitoringActive(this)}, " +
1444
- "carPlay=${isCarPlayEnabled(this)}, keepAlive=$keepAlive). " +
1445
- "Service will remain in foreground."
1678
+ TAG,
1679
+ "onTaskRemoved received (monitoring=${isMonitoringActive(this)}, " +
1680
+ "carPlay=${isCarPlayEnabled(this)}, keepAlive=$keepAlive). " +
1681
+ "Service will remain in foreground."
1446
1682
  )
1447
1683
  super.onTaskRemoved(rootIntent)
1448
1684
  }
@@ -1451,5 +1687,7 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1451
1687
  const val ACTION_BEACON_EVENT = "expo.modules.beacon.BEACON_EVENT"
1452
1688
  /** Intent extra holding the resolved JS event name (e.g. "onBeaconEnter"). */
1453
1689
  internal const val EXTRA_EVENT_NAME = "eventName"
1454
- /** Intent extra holding the event payload as a Bundle, unpacked verbatim by [BeaconEventReceiver]. */
1690
+ /**
1691
+ * Intent extra holding the event payload as a Bundle, unpacked verbatim by [BeaconEventReceiver].
1692
+ */
1455
1693
  internal const val EXTRA_EVENT_PARAMS = "params"