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.
- package/README.md +59 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/expo/modules/beacon/BeaconEventPlugin.kt +6 -0
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +193 -6
- package/android/src/main/java/expo/modules/beacon/BeaconPluginRegistry.kt +8 -0
- package/android/src/main/java/expo/modules/beacon/BootReceiver.kt +15 -0
- package/android/src/main/java/expo/modules/beacon/CarPlayMonitor.kt +103 -0
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +63 -1
- package/build/ExpoBeacon.types.d.ts +17 -0
- package/build/ExpoBeacon.types.d.ts.map +1 -1
- package/build/ExpoBeacon.types.js.map +1 -1
- package/build/ExpoBeaconModule.d.ts +52 -0
- package/build/ExpoBeaconModule.d.ts.map +1 -1
- package/build/ExpoBeaconModule.js.map +1 -1
- package/build/ExpoBeaconModule.web.d.ts +4 -0
- package/build/ExpoBeaconModule.web.d.ts.map +1 -1
- package/build/ExpoBeaconModule.web.js +4 -0
- package/build/ExpoBeaconModule.web.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/ios/BeaconLifecycleDelegate.swift +20 -0
- package/ios/CarPlayMonitor.swift +133 -0
- package/ios/ExpoBeaconModule.swift +68 -1
- package/package.json +1 -1
- package/plugin/build/withBeaconAndroid.d.ts.map +1 -1
- package/plugin/build/withBeaconAndroid.js +7 -0
- package/plugin/build/withBeaconIOS.d.ts.map +1 -1
- package/plugin/build/withBeaconIOS.js +8 -0
- package/src/ExpoBeacon.types.ts +25 -0
- package/src/ExpoBeaconModule.ts +56 -0
- package/src/ExpoBeaconModule.web.ts +4 -0
- 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()`
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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 = {
|