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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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 =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
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 =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
*
|
|
238
|
-
*
|
|
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(
|
|
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 {
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
299
|
+
timeoutHandler.postDelayed(
|
|
300
|
+
{
|
|
301
|
+
pendingLoadRegions = false
|
|
302
|
+
loadAndMonitorRegions()
|
|
303
|
+
},
|
|
304
|
+
LOAD_REGIONS_DEBOUNCE_MS - elapsed
|
|
305
|
+
)
|
|
289
306
|
}
|
|
290
|
-
Log.d(
|
|
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 =
|
|
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 =
|
|
326
|
+
val eddystonePrefs: SharedPreferences =
|
|
327
|
+
getSharedPreferences(EDDYSTONE_PREFS_NAME, Context.MODE_PRIVATE)
|
|
302
328
|
val eddystoneJson = eddystonePrefs.getString(EDDYSTONE_PREFS_KEY, "[]") ?: "[]"
|
|
303
|
-
val eddystones =
|
|
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 {
|
|
357
|
+
try {
|
|
358
|
+
beaconManager.stopRangingBeaconsInRegion(it)
|
|
359
|
+
} catch (_: RemoteException) {}
|
|
327
360
|
}
|
|
328
361
|
distanceLogRegions.clear()
|
|
329
362
|
monitoredRegions.forEach {
|
|
330
|
-
try {
|
|
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 =
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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(
|
|
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(
|
|
371
|
-
|
|
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(
|
|
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(
|
|
384
|
-
|
|
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 =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
467
|
+
sendErrorBroadcast(
|
|
468
|
+
identifier,
|
|
469
|
+
"MONITORING_FAILED",
|
|
470
|
+
"Failed to start monitoring Eddystone region $identifier"
|
|
471
|
+
)
|
|
408
472
|
} catch (ex: SecurityException) {
|
|
409
|
-
Log.e(
|
|
410
|
-
|
|
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(
|
|
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(
|
|
422
|
-
|
|
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 =
|
|
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 =
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
556
|
+
lastSeenAtMs.remove(region.uniqueId)
|
|
464
557
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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 =
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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(
|
|
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 =
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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" ->
|
|
736
|
-
|
|
737
|
-
|
|
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 =
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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 =
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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 =
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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 =
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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 =
|
|
829
|
-
|
|
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
|
-
/**
|
|
984
|
+
/**
|
|
985
|
+
* Build and post a user-facing event notification; silently skipped without POST_NOTIFICATIONS.
|
|
986
|
+
*/
|
|
835
987
|
private fun postEventNotification(
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
988
|
+
channelId: String,
|
|
989
|
+
eventsConfig: org.json.JSONObject?,
|
|
990
|
+
title: String,
|
|
991
|
+
message: String,
|
|
992
|
+
notifId: Int
|
|
841
993
|
) {
|
|
842
|
-
val notification =
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
*
|
|
862
|
-
*
|
|
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 =
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
*
|
|
890
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1049
|
+
* ```
|
|
1050
|
+
* 2. Observer running + car disconnected + JS last knew "connected" →
|
|
1051
|
+
* ```
|
|
896
1052
|
* emit synthetic `onCarPlayDisconnected` with `reason = "reconciled"`.
|
|
897
|
-
*
|
|
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
|
|
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 {
|
|
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 =
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
947
|
-
*
|
|
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 {
|
|
1130
|
+
try {
|
|
1131
|
+
apiForwarder?.forwardEvent(payload, eventName)
|
|
1132
|
+
} catch (_: Throwable) {}
|
|
961
1133
|
// Native plugin registry (BeaconGeoPlugin etc.).
|
|
962
1134
|
when (eventName) {
|
|
963
|
-
"onCarPlayConnected" ->
|
|
964
|
-
|
|
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" ->
|
|
979
|
-
|
|
980
|
-
|
|
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 =
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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 =
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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 =
|
|
1009
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1077
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1114
|
-
*
|
|
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 =
|
|
1127
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1345
|
+
.edit()
|
|
1346
|
+
.clear()
|
|
1347
|
+
.apply()
|
|
1153
1348
|
} catch (_: Throwable) {}
|
|
1154
|
-
val intent =
|
|
1155
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1189
|
-
*
|
|
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 =
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
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 =
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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 =
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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? =
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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 =
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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 =
|
|
1276
|
-
|
|
1277
|
-
val channelDesc =
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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 =
|
|
1290
|
-
|
|
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
|
-
*
|
|
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) =
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
*
|
|
1312
|
-
*
|
|
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) =
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
-
*
|
|
1326
|
-
*
|
|
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 =
|
|
1337
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
*
|
|
1357
|
-
*
|
|
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 {
|
|
1600
|
+
try {
|
|
1601
|
+
beaconManager.stopRangingBeaconsInRegion(it)
|
|
1602
|
+
} catch (_: RemoteException) {}
|
|
1369
1603
|
}
|
|
1370
1604
|
distanceLogRegions.clear()
|
|
1371
1605
|
monitoredRegions.forEach {
|
|
1372
|
-
try {
|
|
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 {
|
|
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
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
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
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
/**
|
|
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"
|