expo-beacon 0.7.26 → 0.8.1

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.
Files changed (33) hide show
  1. package/README.md +59 -0
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/expo/modules/beacon/BeaconEventPlugin.kt +6 -0
  4. package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +193 -6
  5. package/android/src/main/java/expo/modules/beacon/BeaconPluginRegistry.kt +8 -0
  6. package/android/src/main/java/expo/modules/beacon/BootReceiver.kt +15 -0
  7. package/android/src/main/java/expo/modules/beacon/CarPlayMonitor.kt +103 -0
  8. package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +63 -1
  9. package/build/ExpoBeacon.types.d.ts +17 -0
  10. package/build/ExpoBeacon.types.d.ts.map +1 -1
  11. package/build/ExpoBeacon.types.js.map +1 -1
  12. package/build/ExpoBeaconModule.d.ts +52 -0
  13. package/build/ExpoBeaconModule.d.ts.map +1 -1
  14. package/build/ExpoBeaconModule.js.map +1 -1
  15. package/build/ExpoBeaconModule.web.d.ts +4 -0
  16. package/build/ExpoBeaconModule.web.d.ts.map +1 -1
  17. package/build/ExpoBeaconModule.web.js +4 -0
  18. package/build/ExpoBeaconModule.web.js.map +1 -1
  19. package/build/index.d.ts +1 -1
  20. package/build/index.d.ts.map +1 -1
  21. package/build/index.js.map +1 -1
  22. package/ios/BeaconLifecycleDelegate.swift +20 -0
  23. package/ios/CarPlayMonitor.swift +133 -0
  24. package/ios/ExpoBeaconModule.swift +68 -1
  25. package/package.json +1 -1
  26. package/plugin/build/withBeaconAndroid.d.ts.map +1 -1
  27. package/plugin/build/withBeaconAndroid.js +7 -0
  28. package/plugin/build/withBeaconIOS.d.ts.map +1 -1
  29. package/plugin/build/withBeaconIOS.js +8 -0
  30. package/src/ExpoBeacon.types.ts +25 -0
  31. package/src/ExpoBeaconModule.ts +56 -0
  32. package/src/ExpoBeaconModule.web.ts +4 -0
  33. package/src/index.ts +3 -0
package/README.md CHANGED
@@ -687,6 +687,65 @@ try {
687
687
 
688
688
  ---
689
689
 
690
+ ### CarPlay / Android Auto Detection
691
+
692
+ Detect when the device connects to a car infotainment system and react in JS — or, when the bundled config plugin is installed, automatically start `react-native-background-geolocation` tracking on connect and stop it on disconnect.
693
+
694
+ Detection covers both **wired and wireless CarPlay** on iOS and **Android Auto projection / Android Automotive OS** on Android. No special CarPlay entitlement or Android Auto certification is required.
695
+
696
+ ```ts
697
+ import ExpoBeacon, {
698
+ CarPlayConnectedEvent,
699
+ CarPlayDisconnectedEvent,
700
+ } from "expo-beacon";
701
+
702
+ // Start observing
703
+ await ExpoBeacon.startCarPlayMonitoring();
704
+
705
+ const connectSub = ExpoBeacon.addListener(
706
+ "onCarPlayConnected",
707
+ (event: CarPlayConnectedEvent) => {
708
+ // event.transport: "wired" | "wireless" | "projection" | "native" | "unknown"
709
+ console.log(`Car connected via ${event.transport}`);
710
+ },
711
+ );
712
+ const disconnectSub = ExpoBeacon.addListener(
713
+ "onCarPlayDisconnected",
714
+ (_event: CarPlayDisconnectedEvent) => {
715
+ console.log("Car disconnected");
716
+ },
717
+ );
718
+
719
+ // Stop later (e.g. when feature is disabled)
720
+ await ExpoBeacon.stopCarPlayMonitoring();
721
+ connectSub.remove();
722
+ disconnectSub.remove();
723
+ ```
724
+
725
+ **How it works**
726
+
727
+ - **iOS:** observes `AVAudioSession.routeChangeNotification` for output ports of type `.carAudio`. Wired-vs-wireless is reported on a best-effort basis (looking for a coexisting Bluetooth output port).
728
+ - **Android:** observes `androidx.car.app.connection.CarConnection` LiveData. `transport` is `"projection"` for phones casting to a head unit, `"native"` for Android Automotive OS.
729
+ - Connect/disconnect events flow through the same SQLite event log and remote API forwarder as beacon events.
730
+ - When the config plugin is installed, the auto-generated `BeaconGeoPlugin` also calls `BackgroundGeolocation.start()` on connect and `.stop()` on disconnect — no extra wiring required.
731
+
732
+ **Background detection**
733
+
734
+ CarPlay observation is **persistent** — the enabled flag is stored in native preferences and the observer is automatically re-attached after app kill or device reboot. `startMonitoring()` also enables CarPlay observation by default; calling `startCarPlayMonitoring()` explicitly is only required if you want CarPlay events without beacon monitoring.
735
+
736
+ - **Android:** the foreground service hosts the `CarConnection` observer. As long as the service runs (which it does whenever beacon monitoring or CarPlay monitoring is enabled, and is restarted on boot by `BootReceiver`), CarPlay events are captured even after the app process is killed. **Guaranteed background detection.**
737
+ - **iOS:** the observer auto-restarts in the module's `OnCreate`, including background-launches triggered by beacon region monitoring. **iOS cannot wake a terminated app on CarPlay alone** — for guaranteed wake-from-suspension, also call `startMonitoring()` with at least one paired beacon (e.g. a beacon left in the vehicle). Region-wake events trigger a CarPlay state resync to reconcile any route changes that happened while the app was suspended.
738
+
739
+ **Notes**
740
+
741
+ - `startCarPlayMonitoring()` is idempotent. Calling it twice does not register a duplicate observer.
742
+ - `stopCarPlayMonitoring()` clears the persisted flag, so the observer will not auto-restart on next launch.
743
+ - The iOS detector does not require the CarPlay entitlement because it only reads the active audio route; you do not need to ship a CarPlay app.
744
+ - On iOS, if the JS bundle is suspended in the background, the JS event delivery is deferred until the app resumes, but the native lifecycle delegate (used by the geolocation plugin) fires immediately on connect.
745
+ - On Android, when CarPlay monitoring is enabled without beacon monitoring, the foreground service shows a generic "Connected device monitoring active" notification.
746
+
747
+ ---
748
+
690
749
  ## Full API Reference
691
750
 
692
751
  ### `requestPermissionsAsync()`
@@ -20,4 +20,7 @@ android {
20
20
 
21
21
  dependencies {
22
22
  implementation 'org.altbeacon:android-beacon-library:2.21.2'
23
+ // androidx.car.app provides CarConnection LiveData for detecting Android Auto sessions.
24
+ // No Android Auto certification or extra permissions required for read-only state.
25
+ implementation 'androidx.car.app:app:1.4.0'
23
26
  }
@@ -16,4 +16,10 @@ interface BeaconEventPlugin {
16
16
  fun onEddystoneEnter(identifier: String, namespace: String, instance: String, distance: Double)
17
17
  fun onEddystoneExit(identifier: String, namespace: String, instance: String, distance: Double)
18
18
  fun onEddystoneTimeout(identifier: String, namespace: String, instance: String, distance: Double)
19
+
20
+ // CarPlay / Android Auto
21
+ /** Called when the device connects to an Android Auto session. Default no-op. */
22
+ fun onCarPlayConnected(transport: String) {}
23
+ /** Called when the device disconnects from an Android Auto session. Default no-op. */
24
+ fun onCarPlayDisconnected() {}
19
25
  }
@@ -84,6 +84,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
84
84
  // Seconds of silence after last valid sighting before a disappearance-based exit fires.
85
85
  @Volatile private var exitTimeoutMs: Long = (DEFAULT_EXIT_TIMEOUT_SECONDS * 1000.0).toLong()
86
86
 
87
+ // CarPlay / Android Auto observer hosted by the foreground service so that
88
+ // CarConnection events are captured for as long as the service runs —
89
+ // independent of the JS bridge / module lifecycle. Survives app suspension
90
+ // and (via BootReceiver) device reboot.
91
+ @Volatile private var carPlayMonitor: CarPlayMonitor? = null
92
+
87
93
  override fun onCreate() {
88
94
  super.onCreate()
89
95
  activeService = this
@@ -106,6 +112,14 @@ class BeaconForegroundService : Service(), BeaconConsumer {
106
112
  // process and inherits the elevated priority. Calling enable/disable on the
107
113
  // shared singleton causes crashes when the ExpoBeaconModule has an active
108
114
  // scan bound to the same BeaconManager.
115
+
116
+ // Restore CarPlay observer if the user previously enabled CarPlay
117
+ // monitoring. Reading the flag here means cold-started services
118
+ // (e.g. via BootReceiver after reboot) automatically re-attach the
119
+ // observer with no JS interaction required.
120
+ if (isCarPlayEnabled(this)) {
121
+ startCarPlayObserverInternal()
122
+ }
109
123
  }
110
124
 
111
125
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -134,11 +148,32 @@ class BeaconForegroundService : Service(), BeaconConsumer {
134
148
  stopSelf()
135
149
  return START_NOT_STICKY
136
150
  }
151
+ // Handle CarPlay enable/disable actions. These can arrive after the
152
+ // service is already running for beacon monitoring, OR be the reason
153
+ // the service was started in the first place (CarPlay-only mode).
154
+ when (intent?.action) {
155
+ ACTION_ENABLE_CARPLAY -> {
156
+ setCarPlayEnabled(this, true)
157
+ startCarPlayObserverInternal()
158
+ }
159
+ ACTION_DISABLE_CARPLAY -> {
160
+ setCarPlayEnabled(this, false)
161
+ stopCarPlayObserverInternal()
162
+ // If the service is only alive for CarPlay (no beacon monitoring),
163
+ // shut it down so we don't keep an unnecessary foreground notification.
164
+ if (!isMonitoringActive(this)) {
165
+ stopSelf()
166
+ return START_NOT_STICKY
167
+ }
168
+ }
169
+ }
137
170
  if (serviceConnected) {
138
171
  // Already bound from a prior onStartCommand — reload regions directly
139
172
  // so that re-starting monitoring from JS always takes effect.
140
173
  loadAndMonitorRegions()
141
- } else {
174
+ } else if (isMonitoringActive(this)) {
175
+ // Only bind to AltBeacon when beacon monitoring is active.
176
+ // CarPlay-only mode keeps the service alive without scanning.
142
177
  beaconManager.bind(this)
143
178
  }
144
179
  return START_STICKY
@@ -765,6 +800,64 @@ class BeaconForegroundService : Service(), BeaconConsumer {
765
800
  }
766
801
  }
767
802
 
803
+ // MARK: - CarPlay observer (service-hosted)
804
+
805
+ /**
806
+ * Lazily instantiate and start the CarPlay observer. Idempotent —
807
+ * `CarPlayMonitor.start` itself is safe to call multiple times.
808
+ * Must be called from any thread; the monitor hops to main internally.
809
+ */
810
+ private fun startCarPlayObserverInternal() {
811
+ val monitor = carPlayMonitor ?: try {
812
+ CarPlayMonitor(applicationContext).also { carPlayMonitor = it }
813
+ } catch (e: Throwable) {
814
+ Log.w(TAG, "Failed to create CarPlayMonitor", e)
815
+ return
816
+ }
817
+ try {
818
+ monitor.start { eventName, payload ->
819
+ emitCarPlayEvent(eventName, payload)
820
+ }
821
+ } catch (e: Throwable) {
822
+ Log.w(TAG, "Failed to start CarPlayMonitor", e)
823
+ }
824
+ }
825
+
826
+ private fun stopCarPlayObserverInternal() {
827
+ carPlayMonitor?.stop()
828
+ // Keep the instance — it's idempotent, and recreating costs nothing
829
+ // beyond the `CarConnection` LiveData wrapper. Setting null here is
830
+ // also safe but unnecessary.
831
+ }
832
+
833
+ /**
834
+ * Fan out a CarPlay event to all sinks: SQLite log, remote API forwarder,
835
+ * native plugin registry, and (best-effort) the live JS bridge. Runs from
836
+ * the main thread (CarPlayMonitor's emit hop).
837
+ */
838
+ private fun emitCarPlayEvent(eventName: String, payload: Map<String, Any?>) {
839
+ // SQLite log (only if event logging is enabled).
840
+ try {
841
+ if (BeaconEventLogger.isLoggingEnabled(this)) {
842
+ val identifier = payload["identifier"] as? String
843
+ getOrCreateEventLogger().logEvent(eventName, identifier, payload)
844
+ }
845
+ } catch (e: Throwable) {
846
+ Log.w(TAG, "CarPlay log write failed", e)
847
+ }
848
+ // Remote API forwarder (no-op if unconfigured).
849
+ try { apiForwarder?.forwardEvent(payload) } catch (_: Throwable) {}
850
+ // Native plugin registry (BeaconGeoPlugin etc.).
851
+ when (eventName) {
852
+ "onCarPlayConnected" -> BeaconPluginRegistry.dispatchCarPlayConnected(
853
+ payload["transport"] as? String ?: "unknown"
854
+ )
855
+ "onCarPlayDisconnected" -> BeaconPluginRegistry.dispatchCarPlayDisconnected()
856
+ }
857
+ // Best-effort delivery to the live JS bridge if a module instance is bound.
858
+ try { boundModule?.get()?.forwardCarPlayEventFromService(eventName, payload) } catch (_: Throwable) {}
859
+ }
860
+
768
861
  private fun createNotificationChannel() {
769
862
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
770
863
  val config = readNotificationConfig()
@@ -814,13 +907,20 @@ class BeaconForegroundService : Service(), BeaconConsumer {
814
907
  const val DISTANCE_JUMP_FACTOR = 5.0
815
908
 
816
909
  private const val PREF_IS_MONITORING = "expo.beacon.is_monitoring"
910
+ private const val PREF_CARPLAY_ENABLED = "expo.beacon.carplay_enabled"
817
911
  private const val EXTRA_RETRY_COUNT = "retryCount"
912
+ /** Intent action: enable CarPlay/Android Auto observation in the foreground service. */
913
+ const val ACTION_ENABLE_CARPLAY = "expo.modules.beacon.ENABLE_CARPLAY"
914
+ /** Intent action: disable CarPlay/Android Auto observation. Stops the service if no other reason to run. */
915
+ const val ACTION_DISABLE_CARPLAY = "expo.modules.beacon.DISABLE_CARPLAY"
818
916
  private const val MAX_STARTFOREGROUND_RETRIES = 3
819
917
  private const val RETRY_DELAY_MS = 10_000L
820
918
  private const val RETRY_SERVICE_REQUEST_CODE = 0x42454143 // "BEAC"
821
919
  /** Minimum milliseconds between consecutive loadAndMonitorRegions() calls. */
822
920
  private const val LOAD_REGIONS_DEBOUNCE_MS = 500L
823
921
  @Volatile private var activeService: BeaconForegroundService? = null
922
+ /** Weak reference to the live ExpoBeaconModule for best-effort JS bridge fan-out. */
923
+ @Volatile private var boundModule: java.lang.ref.WeakReference<ExpoBeaconModule>? = null
824
924
 
825
925
  fun start(context: Context) {
826
926
  context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
@@ -837,6 +937,12 @@ class BeaconForegroundService : Service(), BeaconConsumer {
837
937
  fun stop(context: Context) {
838
938
  context.getSharedPreferences(PREF_IS_MONITORING, Context.MODE_PRIVATE)
839
939
  .edit().putBoolean("active", false).apply()
940
+ // Keep the service alive if it's still needed for CarPlay observation;
941
+ // otherwise the user would silently lose CarPlay events when calling
942
+ // stopMonitoring() while CarPlay monitoring was independently enabled.
943
+ if (isCarPlayEnabled(context)) {
944
+ return
945
+ }
840
946
  context.stopService(Intent(context, BeaconForegroundService::class.java))
841
947
  }
842
948
 
@@ -845,6 +951,70 @@ class BeaconForegroundService : Service(), BeaconConsumer {
845
951
  .getBoolean("active", false)
846
952
  }
847
953
 
954
+ // MARK: - CarPlay public API
955
+
956
+ /**
957
+ * Persist whether the user wants CarPlay observation. Cold service starts
958
+ * (e.g. via [BootReceiver]) read this flag in [onCreate] and re-attach
959
+ * the observer automatically.
960
+ */
961
+ internal fun setCarPlayEnabled(context: Context, enabled: Boolean) {
962
+ context.getSharedPreferences(PREF_CARPLAY_ENABLED, Context.MODE_PRIVATE)
963
+ .edit().putBoolean("enabled", enabled).apply()
964
+ }
965
+
966
+ fun isCarPlayEnabled(context: Context): Boolean {
967
+ return context.getSharedPreferences(PREF_CARPLAY_ENABLED, Context.MODE_PRIVATE)
968
+ .getBoolean("enabled", false)
969
+ }
970
+
971
+ /**
972
+ * Enable CarPlay observation. Starts the foreground service if it's not
973
+ * already running so the observer survives app suspension and process death.
974
+ * Idempotent and safe to call from any context.
975
+ */
976
+ fun enableCarPlay(context: Context) {
977
+ setCarPlayEnabled(context, true)
978
+ ensureNotificationChannel(context)
979
+ val intent = Intent(context, BeaconForegroundService::class.java)
980
+ .setAction(ACTION_ENABLE_CARPLAY)
981
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
982
+ context.startForegroundService(intent)
983
+ } else {
984
+ context.startService(intent)
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Disable CarPlay observation. The foreground service stops itself
990
+ * if no other monitoring reason remains.
991
+ */
992
+ fun disableCarPlay(context: Context) {
993
+ setCarPlayEnabled(context, false)
994
+ val intent = Intent(context, BeaconForegroundService::class.java)
995
+ .setAction(ACTION_DISABLE_CARPLAY)
996
+ // Best-effort: if the service isn't running, sending the intent will
997
+ // start it just to stop it. Skip startForegroundService unless beacon
998
+ // monitoring is also active (otherwise the start might fail with
999
+ // ForegroundServiceStartNotAllowedException on some Android versions).
1000
+ if (activeService != null || isMonitoringActive(context)) {
1001
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1002
+ context.startForegroundService(intent)
1003
+ } else {
1004
+ context.startService(intent)
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * Bind a live module instance for best-effort JS-bridge delivery of
1011
+ * service-emitted events. The reference is weak; pass `null` from the
1012
+ * module's OnDestroy to clear it.
1013
+ */
1014
+ fun bindModule(module: ExpoBeaconModule?) {
1015
+ boundModule = module?.let { java.lang.ref.WeakReference(it) }
1016
+ }
1017
+
848
1018
  fun getMonitoringRuntimeSnapshot(): Map<String, MonitoringRuntimeState> {
849
1019
  return activeService?.snapshotMonitoringRuntimeState() ?: emptyMap()
850
1020
  }
@@ -891,10 +1061,16 @@ class BeaconForegroundService : Service(), BeaconConsumer {
891
1061
  val config = try { org.json.JSONObject(json ?: "") } catch (_: Exception) { org.json.JSONObject() }
892
1062
  val fgConfig = config.optJSONObject("foregroundService")
893
1063
 
894
- val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() }
895
- ?: "Beacon Monitoring Active"
896
- val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() }
897
- ?: "Monitoring for iBeacons in the background"
1064
+ // If beacon monitoring is not active and the service is alive only
1065
+ // for CarPlay observation, fall back to a generic notification so
1066
+ // users don't see "Monitoring for iBeacons" when no beacons are being
1067
+ // monitored.
1068
+ val carPlayOnly = !isMonitoringActive(context) && isCarPlayEnabled(context)
1069
+ val defaultTitle = if (carPlayOnly) "Connected device monitoring active" else "Beacon Monitoring Active"
1070
+ val defaultText = if (carPlayOnly) "Monitoring connected vehicle (CarPlay/Android Auto)" else "Monitoring for iBeacons in the background"
1071
+
1072
+ val title = fgConfig?.optString("title")?.takeIf { it.isNotEmpty() } ?: defaultTitle
1073
+ val text = fgConfig?.optString("text")?.takeIf { it.isNotEmpty() } ?: defaultText
898
1074
  val iconName = fgConfig?.optString("icon")?.takeIf { it.isNotEmpty() }
899
1075
  val iconResId = iconName?.let { name ->
900
1076
  try { context.resources.getIdentifier(name, "drawable", context.packageName).takeIf { it != 0 } }
@@ -921,6 +1097,13 @@ class BeaconForegroundService : Service(), BeaconConsumer {
921
1097
  if (activeService === this) {
922
1098
  activeService = null
923
1099
  }
1100
+ // Stop CarPlay observer first so the LiveData observer doesn't leak
1101
+ // past the service lifecycle. Safe even if it was never started.
1102
+ try {
1103
+ carPlayMonitor?.stop()
1104
+ } catch (_: Throwable) {}
1105
+ carPlayMonitor = null
1106
+ val wasBound = serviceConnected
924
1107
  serviceConnected = false
925
1108
  timeoutHandler.removeCallbacksAndMessages(null)
926
1109
  timeoutRunnables.clear()
@@ -944,7 +1127,11 @@ class BeaconForegroundService : Service(), BeaconConsumer {
944
1127
  monitoredRegions.forEach {
945
1128
  try { beaconManager.stopMonitoringBeaconsInRegion(it) } catch (_: RemoteException) {}
946
1129
  }
947
- beaconManager.unbind(this)
1130
+ // Only unbind if we successfully bound — CarPlay-only service
1131
+ // instances skip beaconManager.bind() in onStartCommand.
1132
+ if (wasBound) {
1133
+ try { beaconManager.unbind(this) } catch (_: Throwable) {}
1134
+ }
948
1135
  super.onDestroy()
949
1136
  }
950
1137
 
@@ -77,4 +77,12 @@ object BeaconPluginRegistry {
77
77
  }
78
78
  }
79
79
  }
80
+
81
+ internal fun dispatchCarPlayConnected(transport: String) {
82
+ plugins.forEach { it.onCarPlayConnected(transport) }
83
+ }
84
+
85
+ internal fun dispatchCarPlayDisconnected() {
86
+ plugins.forEach { it.onCarPlayDisconnected() }
87
+ }
80
88
  }
@@ -29,11 +29,16 @@ class BootReceiver : BroadcastReceiver() {
29
29
  logMemoryKillDiagnostics(context)
30
30
  if (BeaconForegroundService.isMonitoringActive(context)) {
31
31
  tryStartService(context)
32
+ } else if (BeaconForegroundService.isCarPlayEnabled(context)) {
33
+ // CarPlay-only mode: re-attach the observer so it survives reboot.
34
+ tryEnableCarPlay(context)
32
35
  }
33
36
  }
34
37
  ACTION_RETRY_MONITORING -> {
35
38
  if (BeaconForegroundService.isMonitoringActive(context)) {
36
39
  tryStartService(context)
40
+ } else if (BeaconForegroundService.isCarPlayEnabled(context)) {
41
+ tryEnableCarPlay(context)
37
42
  }
38
43
  }
39
44
  }
@@ -54,6 +59,16 @@ class BootReceiver : BroadcastReceiver() {
54
59
  }
55
60
  }
56
61
 
62
+ private fun tryEnableCarPlay(context: Context) {
63
+ try {
64
+ BeaconForegroundService.enableCarPlay(context)
65
+ Log.d(TAG, "BootReceiver: CarPlay-only service started successfully")
66
+ } catch (e: Exception) {
67
+ Log.e(TAG, "BootReceiver: Failed to start CarPlay-only service — retrying in ${RETRY_DELAY_MS}ms", e)
68
+ scheduleRetry(context)
69
+ }
70
+ }
71
+
57
72
  private fun scheduleRetry(context: Context) {
58
73
  val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
59
74
  val retryIntent = Intent(context, BootReceiver::class.java).apply {
@@ -0,0 +1,103 @@
1
+ package expo.modules.beacon
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import androidx.car.app.connection.CarConnection
8
+ import androidx.lifecycle.LiveData
9
+ import androidx.lifecycle.Observer
10
+
11
+ /**
12
+ * Wraps [CarConnection] LiveData to surface Android Auto / Automotive OS
13
+ * connection events. No special permissions or Android Auto certification
14
+ * are required — `CarConnection.type` is read-only state.
15
+ *
16
+ * Lifecycle: this monitor uses `observeForever` and therefore must be
17
+ * explicitly stopped to avoid leaks. The owning module is responsible for
18
+ * calling [stop] in `OnDestroy`.
19
+ *
20
+ * All observer registration / removal happens on the main thread because
21
+ * [LiveData.observeForever] requires it.
22
+ */
23
+ internal class CarPlayMonitor(private val context: Context) {
24
+
25
+ /** Emit callback signature: (eventName, payload). */
26
+ fun interface Emit {
27
+ operator fun invoke(eventName: String, payload: Map<String, Any?>)
28
+ }
29
+
30
+ private val mainHandler = Handler(Looper.getMainLooper())
31
+ private val liveData: LiveData<Int> = CarConnection(context.applicationContext).type
32
+ private var observer: Observer<Int>? = null
33
+ private var emit: Emit? = null
34
+ @Volatile private var lastConnected: Boolean? = null
35
+
36
+ /**
37
+ * Begin observing connection state. Idempotent — calling twice replaces the
38
+ * emit callback but does not register a duplicate observer.
39
+ * Emits `onCarPlayConnected` immediately if already connected.
40
+ */
41
+ fun start(emit: Emit) {
42
+ runOnMain {
43
+ this.emit = emit
44
+ if (observer == null) {
45
+ val obs = Observer<Int> { type -> handleType(type) }
46
+ observer = obs
47
+ try {
48
+ liveData.observeForever(obs)
49
+ Log.d(TAG, "CarPlay monitoring started")
50
+ } catch (e: Exception) {
51
+ Log.w(TAG, "Failed to start CarPlay monitoring: ${e.message}")
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Stop observing connection state and release the emit callback. */
58
+ fun stop() {
59
+ runOnMain {
60
+ observer?.let {
61
+ try { liveData.removeObserver(it) } catch (_: Exception) {}
62
+ }
63
+ observer = null
64
+ emit = null
65
+ lastConnected = null
66
+ Log.d(TAG, "CarPlay monitoring stopped")
67
+ }
68
+ }
69
+
70
+ private fun handleType(type: Int) {
71
+ val connected = type != CarConnection.CONNECTION_TYPE_NOT_CONNECTED
72
+ if (lastConnected == connected) return
73
+ lastConnected = connected
74
+ val callback = emit ?: return
75
+ if (connected) {
76
+ val transport = when (type) {
77
+ CarConnection.CONNECTION_TYPE_PROJECTION -> "projection"
78
+ CarConnection.CONNECTION_TYPE_NATIVE -> "native"
79
+ else -> "unknown"
80
+ }
81
+ callback("onCarPlayConnected", mapOf(
82
+ "transport" to transport,
83
+ "timestamp" to System.currentTimeMillis(),
84
+ ))
85
+ } else {
86
+ callback("onCarPlayDisconnected", mapOf(
87
+ "timestamp" to System.currentTimeMillis(),
88
+ ))
89
+ }
90
+ }
91
+
92
+ private fun runOnMain(block: () -> Unit) {
93
+ if (Looper.myLooper() == Looper.getMainLooper()) {
94
+ block()
95
+ } else {
96
+ mainHandler.post(block)
97
+ }
98
+ }
99
+
100
+ private companion object {
101
+ const val TAG = "CarPlayMonitor"
102
+ }
103
+ }
@@ -85,7 +85,7 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
85
85
  override fun definition() = ModuleDefinition {
86
86
  Name("ExpoBeacon")
87
87
 
88
- Events("onBeaconEnter", "onBeaconExit", "onBeaconDistance", "onBeaconTimeout", "onBeaconFound", "onEddystoneFound", "onEddystoneEnter", "onEddystoneExit", "onEddystoneDistance", "onEddystoneTimeout", "onBeaconError")
88
+ Events("onBeaconEnter", "onBeaconExit", "onBeaconDistance", "onBeaconTimeout", "onBeaconFound", "onEddystoneFound", "onEddystoneEnter", "onEddystoneExit", "onEddystoneDistance", "onEddystoneTimeout", "onBeaconError", "onCarPlayConnected", "onCarPlayDisconnected")
89
89
 
90
90
  AsyncFunction("scanForBeaconsAsync") { uuids: List<String>?, scanDurationMs: Int, promise: Promise ->
91
91
  if (scanDurationMs <= 0) {
@@ -376,6 +376,10 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
376
376
  sendEvent("onBeaconError", mapOf("identifier" to "", "code" to "SERVICE_START_FAILED", "message" to "Failed to start monitoring service: ${e.message}"))
377
377
  return@AsyncFunction
378
378
  }
379
+ // Auto-enable CarPlay observation alongside beacon monitoring so
380
+ // the service captures CarPlay/Android Auto events for the same
381
+ // lifetime. Users can opt out at any time via stopCarPlayMonitoring().
382
+ try { BeaconForegroundService.enableCarPlay(ctx) } catch (_: Throwable) {}
379
383
  promise.resolve(null)
380
384
  }
381
385
 
@@ -459,6 +463,11 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
459
463
  loggingEnabled = false
460
464
  }
461
465
 
466
+ Function("isEventLoggingEnabled") {
467
+ val ctx = appContext.reactContext ?: return@Function false
468
+ BeaconEventLogger.isLoggingEnabled(ctx)
469
+ }
470
+
462
471
  Function("getEventLogs") { options: Map<String, Any?>? ->
463
472
  val logger = getOrCreateEventLogger() ?: return@Function emptyList<Map<String, Any?>>()
464
473
  val limit = (options?.get("limit") as? Number)?.toInt() ?: 1000
@@ -562,8 +571,51 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
562
571
  }
563
572
  }
564
573
 
574
+ // MARK: - CarPlay / Android Auto
575
+
576
+ AsyncFunction("startCarPlayMonitoring") { promise: Promise ->
577
+ val ctx = appContext.reactContext
578
+ if (ctx == null) {
579
+ promise.reject("NO_CONTEXT", "React context is not available", null)
580
+ return@AsyncFunction
581
+ }
582
+ try {
583
+ BeaconForegroundService.enableCarPlay(ctx)
584
+ promise.resolve(null)
585
+ } catch (e: Throwable) {
586
+ promise.reject("CARPLAY_START_FAILED", "Failed to start CarPlay monitoring: ${e.message}", e)
587
+ }
588
+ }
589
+
590
+ AsyncFunction("stopCarPlayMonitoring") { promise: Promise ->
591
+ val ctx = appContext.reactContext
592
+ if (ctx == null) {
593
+ promise.reject("NO_CONTEXT", "React context is not available", null)
594
+ return@AsyncFunction
595
+ }
596
+ try {
597
+ BeaconForegroundService.disableCarPlay(ctx)
598
+ promise.resolve(null)
599
+ } catch (e: Throwable) {
600
+ promise.reject("CARPLAY_STOP_FAILED", "Failed to stop CarPlay monitoring: ${e.message}", e)
601
+ }
602
+ }
603
+
604
+ Function("isCarPlayMonitoringEnabled") {
605
+ val ctx = appContext.reactContext ?: return@Function false
606
+ BeaconForegroundService.isCarPlayEnabled(ctx)
607
+ }
608
+
609
+ OnCreate {
610
+ // Register this module instance for best-effort JS-bridge fan-out
611
+ // of CarPlay events emitted by the foreground service. The service
612
+ // holds a weak reference; cleared in OnDestroy.
613
+ BeaconForegroundService.bindModule(this@ExpoBeaconModule)
614
+ }
615
+
565
616
  OnDestroy {
566
617
  with(this@ExpoBeaconModule) {
618
+ BeaconForegroundService.bindModule(null)
567
619
  unregisterEventReceiver()
568
620
  loggingEnabled = false
569
621
  eventLogger?.close()
@@ -1007,4 +1059,14 @@ class ExpoBeaconModule : Module(), BeaconConsumer {
1007
1059
  val identifier = params["identifier"] as? String
1008
1060
  getOrCreateEventLogger()?.logEvent(eventType, identifier, params)
1009
1061
  }
1062
+
1063
+ /**
1064
+ * Called by [BeaconForegroundService] (via weak reference) when it emits a
1065
+ * CarPlay event from its own observer. Best-effort delivery to the JS bridge
1066
+ * — the service has already handled SQLite logging, API forwarding, and
1067
+ * native plugin dispatch, so this method only needs to relay to JS.
1068
+ */
1069
+ fun forwardCarPlayEventFromService(eventName: String, payload: Map<String, Any?>) {
1070
+ try { sendEvent(eventName, payload) } catch (_: Throwable) { /* JS bridge may be torn down */ }
1071
+ }
1010
1072
  }
@@ -254,6 +254,19 @@ export type EddystoneTimeoutEvent = {
254
254
  /** Current distance in metres at the time the timeout fired. */
255
255
  distance: number;
256
256
  };
257
+ /** Transport reported with CarPlay / Android Auto connection events. */
258
+ export type CarPlayTransport = "wired" | "wireless" | "projection" | "native" | "unknown";
259
+ /** Payload fired when the device connects to a CarPlay or Android Auto session. */
260
+ export type CarPlayConnectedEvent = {
261
+ transport: CarPlayTransport;
262
+ /** Timestamp in milliseconds since epoch. */
263
+ timestamp: number;
264
+ };
265
+ /** Payload fired when the device disconnects from a CarPlay or Android Auto session. */
266
+ export type CarPlayDisconnectedEvent = {
267
+ /** Timestamp in milliseconds since epoch. */
268
+ timestamp: number;
269
+ };
257
270
  /** Payload for native beacon error events (monitoring/ranging failures). */
258
271
  export type BeaconErrorEvent = {
259
272
  /** Region or constraint identifier, empty string if unavailable. */
@@ -281,6 +294,10 @@ export type ExpoBeaconModuleEvents = {
281
294
  onEddystoneTimeout: (params: EddystoneTimeoutEvent) => void;
282
295
  /** Fired when a native monitoring or ranging failure occurs (logged to DB and forwarded to JS). */
283
296
  onBeaconError: (params: BeaconErrorEvent) => void;
297
+ /** Fired when the device connects to a CarPlay (iOS) or Android Auto (Android) session. */
298
+ onCarPlayConnected: (params: CarPlayConnectedEvent) => void;
299
+ /** Fired when the device disconnects from a CarPlay (iOS) or Android Auto (Android) session. */
300
+ onCarPlayDisconnected: (params: CarPlayDisconnectedEvent) => void;
284
301
  };
285
302
  /** Options for filtering event logs. */
286
303
  export type EventLogQueryOptions = {