expo-beacon 0.8.8 → 0.8.9

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.
@@ -23,4 +23,9 @@ dependencies {
23
23
  // androidx.car.app provides CarConnection LiveData for detecting Android Auto sessions.
24
24
  // No Android Auto certification or extra permissions required for read-only state.
25
25
  implementation 'androidx.car.app:app:1.4.0'
26
+ // WorkManager powers the CarPlay watchdog (periodic restart of the foreground
27
+ // service if it was killed by the OS / OEM cleaners while CarPlay observation
28
+ // is enabled). Minimum periodic interval is 15 min; an AlarmManager loop in
29
+ // BootReceiver covers the sub-15-min gap.
30
+ implementation 'androidx.work:work-runtime-ktx:2.9.1'
26
31
  }
@@ -79,8 +79,18 @@
79
79
  android:exported="true">
80
80
  <intent-filter>
81
81
  <action android:name="android.intent.action.BOOT_COMPLETED" />
82
+ <!-- Direct-boot phase boot (Android 7+). Harmless without
83
+ directBootAware="true"; declaring early so the only
84
+ follow-up needed for true direct-boot support is that
85
+ single attribute. -->
86
+ <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
87
+ <!-- Restart observers after the host app is updated. -->
88
+ <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
82
89
  <!-- Self-scheduled retry when BT isn't ready at boot -->
83
90
  <action android:name="expo.modules.beacon.ACTION_RETRY_MONITORING" />
91
+ <!-- Self-scheduled CarPlay watchdog (11-min cadence; above
92
+ setExactAndAllowWhileIdle per-app quota). -->
93
+ <action android:name="expo.modules.beacon.ACTION_CARPLAY_WATCHDOG" />
84
94
  </intent-filter>
85
95
  </receiver>
86
96
  </application>
@@ -131,6 +131,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
131
131
  // observer with no JS interaction required.
132
132
  if (isCarPlayEnabled(this)) {
133
133
  startCarPlayObserverInternal()
134
+ // Re-arm the WorkManager watchdog on every cold start. KEEP policy
135
+ // makes this a cheap no-op if a schedule already exists, but it
136
+ // recovers the schedule if WorkManager's DB was wiped by an OEM
137
+ // cleaner or by a `pm clear` from adb.
138
+ CarPlayWatchdogWorker.schedule(this)
134
139
  }
135
140
  }
136
141
 
@@ -1133,6 +1138,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1133
1138
  setCarPlayEnabled(context, true)
1134
1139
  ensureNotificationChannel(context)
1135
1140
  ensureCarPlayNotificationChannel(context)
1141
+ // Arm the WorkManager watchdog (15-min periodic safety net) and the
1142
+ // AlarmManager loop (~11-min cadence, above the OS quota). Both
1143
+ // call back into this same idempotent entry point if the service
1144
+ // is killed while CarPlay observation is enabled.
1145
+ CarPlayWatchdogWorker.schedule(context)
1146
+ BootReceiver.scheduleCarPlayWatchdogAlarm(context)
1136
1147
  val intent = Intent(context, BeaconForegroundService::class.java)
1137
1148
  .setAction(ACTION_ENABLE_CARPLAY)
1138
1149
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -1148,6 +1159,10 @@ class BeaconForegroundService : Service(), BeaconConsumer {
1148
1159
  */
1149
1160
  fun disableCarPlay(context: Context) {
1150
1161
  setCarPlayEnabled(context, false)
1162
+ // Stop the safety-net watchdogs — without this they would keep
1163
+ // restarting the service on every tick.
1164
+ CarPlayWatchdogWorker.cancel(context)
1165
+ BootReceiver.cancelCarPlayWatchdogAlarm(context)
1151
1166
  // Wipe the monitor's persisted last-known connection so a future
1152
1167
  // re-enable starts from a clean slate (no stale "was connected"
1153
1168
  // assumption that would arm the bootstrap-grace re-check).
@@ -11,8 +11,24 @@ import android.os.SystemClock
11
11
  import android.util.Log
12
12
 
13
13
  private const val ACTION_RETRY_MONITORING = "expo.modules.beacon.ACTION_RETRY_MONITORING"
14
+ /**
15
+ * Periodic self-rescheduling watchdog action handled by [BootReceiver].
16
+ * Public on the manifest so the AlarmManager can route back to us after the
17
+ * process has been killed.
18
+ */
19
+ internal const val ACTION_CARPLAY_WATCHDOG = "expo.modules.beacon.ACTION_CARPLAY_WATCHDOG"
14
20
  private const val RETRY_DELAY_MS = 10_000L
15
21
  private const val RETRY_REQUEST_CODE = 0x424F4F54 // "BOOT"
22
+ private const val CARPLAY_WATCHDOG_REQUEST_CODE = 0x43504C57 // "CPLW"
23
+
24
+ /**
25
+ * Cadence for the AlarmManager-based CarPlay watchdog. Set to **11 minutes**
26
+ * so we stay safely above Android's per-app rate limit on
27
+ * `setExactAndAllowWhileIdle()` (~10 minutes on API 23+, tightened again on
28
+ * API 31+). Going lower risks silent coalescing or dropping by the framework.
29
+ * This still beats WorkManager's 15-minute periodic floor.
30
+ */
31
+ private const val CARPLAY_WATCHDOG_INTERVAL_MS = 11L * 60L * 1000L
16
32
 
17
33
  /**
18
34
  * Restarts beacon monitoring after device reboot if it was active before shutdown.
@@ -25,13 +41,19 @@ private const val RETRY_REQUEST_CODE = 0x424F4F54 // "BOOT"
25
41
  class BootReceiver : BroadcastReceiver() {
26
42
  override fun onReceive(context: Context, intent: Intent) {
27
43
  when (intent.action) {
28
- Intent.ACTION_BOOT_COMPLETED -> {
44
+ Intent.ACTION_BOOT_COMPLETED,
45
+ Intent.ACTION_LOCKED_BOOT_COMPLETED,
46
+ Intent.ACTION_MY_PACKAGE_REPLACED -> {
29
47
  logMemoryKillDiagnostics(context)
30
48
  if (BeaconForegroundService.isMonitoringActive(context)) {
31
49
  tryStartService(context)
32
- } else if (BeaconForegroundService.isCarPlayEnabled(context)) {
33
- // CarPlay-only mode: re-attach the observer so it survives reboot.
50
+ }
51
+ if (BeaconForegroundService.isCarPlayEnabled(context)) {
52
+ // CarPlay observation: re-attach the observer so it survives
53
+ // reboot / app update / direct-boot, and arm the
54
+ // self-rescheduling alarm loop.
34
55
  tryEnableCarPlay(context)
56
+ scheduleCarPlayWatchdogAlarm(context)
35
57
  }
36
58
  }
37
59
  ACTION_RETRY_MONITORING -> {
@@ -41,6 +63,18 @@ class BootReceiver : BroadcastReceiver() {
41
63
  tryEnableCarPlay(context)
42
64
  }
43
65
  }
66
+ ACTION_CARPLAY_WATCHDOG -> {
67
+ if (!BeaconForegroundService.isCarPlayEnabled(context)) {
68
+ // CarPlay was disabled since the alarm was set — let the
69
+ // chain die. cancelCarPlayWatchdogAlarm() is also called
70
+ // from disableCarPlay(), this is just defence-in-depth.
71
+ Log.d(TAG, "BootReceiver: watchdog tick skipped (CarPlay disabled)")
72
+ return
73
+ }
74
+ tryEnableCarPlay(context)
75
+ // Reschedule the next tick. setExactAndAllowWhileIdle is one-shot.
76
+ scheduleCarPlayWatchdogAlarm(context)
77
+ }
44
78
  }
45
79
  }
46
80
 
@@ -102,4 +136,69 @@ class BootReceiver : BroadcastReceiver() {
102
136
  }
103
137
  }
104
138
  }
139
+
140
+ companion object {
141
+ /**
142
+ * Arm (or re-arm) the AlarmManager-based CarPlay watchdog. One-shot
143
+ * alarm using `setExactAndAllowWhileIdle` — the receiver re-schedules
144
+ * itself on each fire as long as CarPlay observation remains enabled.
145
+ *
146
+ * Cadence is governed by [CARPLAY_WATCHDOG_INTERVAL_MS], deliberately
147
+ * above the per-app exact-alarm quota.
148
+ *
149
+ * Safe to call from any context; idempotent thanks to
150
+ * `FLAG_UPDATE_CURRENT` on the PendingIntent.
151
+ */
152
+ @JvmStatic
153
+ fun scheduleCarPlayWatchdogAlarm(context: Context) {
154
+ val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
155
+ val intent = Intent(context, BootReceiver::class.java).apply {
156
+ action = ACTION_CARPLAY_WATCHDOG
157
+ // Explicit package so the implicit-broadcast restriction on
158
+ // Android 8+ doesn't drop the delivery.
159
+ `package` = context.packageName
160
+ }
161
+ val pendingIntent = PendingIntent.getBroadcast(
162
+ context,
163
+ CARPLAY_WATCHDOG_REQUEST_CODE,
164
+ intent,
165
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
166
+ )
167
+ try {
168
+ alarmManager.setExactAndAllowWhileIdle(
169
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
170
+ SystemClock.elapsedRealtime() + CARPLAY_WATCHDOG_INTERVAL_MS,
171
+ pendingIntent,
172
+ )
173
+ Log.d(TAG, "BootReceiver: CarPlay watchdog alarm armed (${CARPLAY_WATCHDOG_INTERVAL_MS}ms)")
174
+ } catch (t: Throwable) {
175
+ // Defensive: setExactAndAllowWhileIdle can throw SecurityException on
176
+ // some configurations even though it doesn't strictly require
177
+ // SCHEDULE_EXACT_ALARM. Fall back is the WorkManager 15-min job.
178
+ Log.w(TAG, "BootReceiver: failed to arm CarPlay watchdog alarm", t)
179
+ }
180
+ }
181
+
182
+ /** Cancel the periodic CarPlay watchdog alarm. */
183
+ @JvmStatic
184
+ fun cancelCarPlayWatchdogAlarm(context: Context) {
185
+ val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
186
+ val intent = Intent(context, BootReceiver::class.java).apply {
187
+ action = ACTION_CARPLAY_WATCHDOG
188
+ `package` = context.packageName
189
+ }
190
+ val pendingIntent = PendingIntent.getBroadcast(
191
+ context,
192
+ CARPLAY_WATCHDOG_REQUEST_CODE,
193
+ intent,
194
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
195
+ )
196
+ try {
197
+ alarmManager.cancel(pendingIntent)
198
+ Log.d(TAG, "BootReceiver: CarPlay watchdog alarm cancelled")
199
+ } catch (t: Throwable) {
200
+ Log.w(TAG, "BootReceiver: failed to cancel CarPlay watchdog alarm", t)
201
+ }
202
+ }
203
+ }
105
204
  }
@@ -0,0 +1,89 @@
1
+ package expo.modules.beacon
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import androidx.work.ExistingPeriodicWorkPolicy
6
+ import androidx.work.PeriodicWorkRequestBuilder
7
+ import androidx.work.WorkManager
8
+ import androidx.work.Worker
9
+ import androidx.work.WorkerParameters
10
+ import java.util.concurrent.TimeUnit
11
+
12
+ /**
13
+ * Periodic safety-net that re-arms the [BeaconForegroundService] whenever
14
+ * CarPlay observation is enabled but the service has been killed (low memory,
15
+ * OEM cleaners, etc.).
16
+ *
17
+ * [BeaconForegroundService.enableCarPlay] is idempotent: if the service is
18
+ * alive this is a cheap no-op; if it is dead it cold-starts a fresh
19
+ * foreground instance and re-attaches [CarPlayMonitor].
20
+ *
21
+ * WorkManager guarantees a minimum 15-minute period. A second
22
+ * `AlarmManager` loop in [BootReceiver] covers the sub-15-min gap.
23
+ */
24
+ internal class CarPlayWatchdogWorker(
25
+ appContext: Context,
26
+ params: WorkerParameters,
27
+ ) : Worker(appContext, params) {
28
+
29
+ override fun doWork(): Result {
30
+ val ctx = applicationContext
31
+ if (!BeaconForegroundService.isCarPlayEnabled(ctx)) {
32
+ // User turned CarPlay off — let the periodic work fade out via cancel().
33
+ Log.d(TAG, "Watchdog tick: CarPlay disabled, no action")
34
+ return Result.success()
35
+ }
36
+ try {
37
+ BeaconForegroundService.enableCarPlay(ctx)
38
+ Log.d(TAG, "Watchdog tick: ensured BeaconForegroundService is running for CarPlay")
39
+ } catch (t: Throwable) {
40
+ Log.w(TAG, "Watchdog tick: enableCarPlay failed", t)
41
+ // Returning retry would just defer to the next period; success keeps
42
+ // the periodic schedule running and avoids burning the retry budget.
43
+ }
44
+ return Result.success()
45
+ }
46
+
47
+ companion object {
48
+ private const val TAG = "CarPlayWatchdog"
49
+ private const val WORK_NAME = "expo-beacon-carplay-watchdog"
50
+
51
+ /**
52
+ * Schedule the periodic watchdog. Idempotent — [ExistingPeriodicWorkPolicy.KEEP]
53
+ * preserves the existing schedule across calls so we don't reset the
54
+ * next-run timer every time the user re-enables CarPlay.
55
+ */
56
+ @JvmStatic
57
+ fun schedule(context: Context) {
58
+ try {
59
+ val request = PeriodicWorkRequestBuilder<CarPlayWatchdogWorker>(
60
+ 15, TimeUnit.MINUTES,
61
+ ).addTag(WORK_NAME).build()
62
+ WorkManager.getInstance(context.applicationContext)
63
+ .enqueueUniquePeriodicWork(
64
+ WORK_NAME,
65
+ ExistingPeriodicWorkPolicy.KEEP,
66
+ request,
67
+ )
68
+ Log.d(TAG, "Watchdog scheduled (15 min period)")
69
+ } catch (t: Throwable) {
70
+ // WorkManager.getInstance() can throw IllegalStateException if the
71
+ // host app has not initialised it. Fall back silently — the
72
+ // AlarmManager loop in BootReceiver still provides coverage.
73
+ Log.w(TAG, "Failed to schedule CarPlay watchdog", t)
74
+ }
75
+ }
76
+
77
+ /** Cancel the periodic watchdog. Safe to call when not scheduled. */
78
+ @JvmStatic
79
+ fun cancel(context: Context) {
80
+ try {
81
+ WorkManager.getInstance(context.applicationContext)
82
+ .cancelUniqueWork(WORK_NAME)
83
+ Log.d(TAG, "Watchdog cancelled")
84
+ } catch (t: Throwable) {
85
+ Log.w(TAG, "Failed to cancel CarPlay watchdog", t)
86
+ }
87
+ }
88
+ }
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Expo module for scanning, pairing, and monitoring iBeacons on Android and iOS",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",