expo-beacon 0.8.1 → 0.8.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.
@@ -28,9 +28,6 @@ internal class BeaconApiForwarder(private val context: Context) {
28
28
  context.getSharedPreferences(API_PREFS, Context.MODE_PRIVATE)
29
29
  }
30
30
 
31
- val isConfigured: Boolean
32
- get() = prefs.getString(API_URL_KEY, null)?.isNotEmpty() == true
33
-
34
31
  fun configure(url: String, apiKey: String?, id: String? = null) {
35
32
  prefs.edit().apply {
36
33
  putString(API_URL_KEY, url)
@@ -14,11 +14,6 @@ final class BeaconApiForwarder {
14
14
  private let defaults: UserDefaults
15
15
  private let session: URLSession
16
16
 
17
- var isConfigured: Bool {
18
- guard let url = defaults.string(forKey: API_URL_KEY) else { return false }
19
- return !url.isEmpty
20
- }
21
-
22
17
  init(defaults: UserDefaults? = nil) {
23
18
  self.defaults = defaults ?? (UserDefaults(suiteName: "expo.modules.beacon") ?? .standard)
24
19
  let config = URLSessionConfiguration.default
@@ -0,0 +1,78 @@
1
+ import Foundation
2
+ import UIKit
3
+ import CarPlay
4
+ import os.log
5
+
6
+ /// CarPlay scene delegate used when the consumer app is provisioned with the
7
+ /// `com.apple.developer.carplay-driving-task` entitlement (driver / fleet
8
+ /// workflow apps).
9
+ ///
10
+ /// Wiring is performed by the config plugin (`carplayDrivingTask: true`):
11
+ /// 1. The entitlement is added to the app's `*.entitlements`.
12
+ /// 2. `Info.plist` `UIApplicationSceneManifest` declares a
13
+ /// `CPTemplateApplicationSceneSessionRoleApplication` configuration with
14
+ /// `UISceneDelegateClassName = "ExpoBeacon.BeaconCarPlaySceneDelegate"`.
15
+ ///
16
+ /// When CarPlay attaches, iOS launches (or wakes) the app and instantiates this
17
+ /// delegate. We notify the shared `CarPlayMonitor` which produces an
18
+ /// `onCarPlayConnected` event through the standard pipeline (JS bridge,
19
+ /// SQLite log, HTTP forwarder, lifecycle plugin registry).
20
+ ///
21
+ /// Apple requires that a CarPlay-entitled app present a meaningful CarPlay UI.
22
+ /// We install a minimal `CPInformationTemplate` titled "Driver tracking active"
23
+ /// as the root template so the app passes review without forcing every consumer
24
+ /// to build a full CarPlay UX. Consumers that need richer UI can subclass this
25
+ /// delegate (or replace the scene class) and override `makeRootTemplate()`.
26
+ ///
27
+ /// IMPORTANT: This class must be accessible to the Objective-C runtime so that
28
+ /// `Info.plist`'s `UISceneDelegateClassName` string can resolve via
29
+ /// `NSClassFromString`. Hence `@objc(...)` and `: NSObject`.
30
+ @objc(BeaconCarPlaySceneDelegate)
31
+ open class BeaconCarPlaySceneDelegate: NSObject, CPTemplateApplicationSceneDelegate {
32
+
33
+ private static let log = OSLog(subsystem: "expo.modules.beacon", category: "CarPlayScene")
34
+
35
+ private var interfaceController: CPInterfaceController?
36
+
37
+ public override init() {
38
+ super.init()
39
+ }
40
+
41
+ // MARK: - CPTemplateApplicationSceneDelegate
42
+
43
+ public func templateApplicationScene(
44
+ _ templateApplicationScene: CPTemplateApplicationScene,
45
+ didConnect interfaceController: CPInterfaceController
46
+ ) {
47
+ os_log("CarPlay templateApplicationScene didConnect", log: Self.log, type: .info)
48
+ self.interfaceController = interfaceController
49
+ let template = makeRootTemplate()
50
+ interfaceController.setRootTemplate(template, animated: false, completion: nil)
51
+ CarPlayMonitor.shared.notifyEntitledConnect()
52
+ }
53
+
54
+ public func templateApplicationScene(
55
+ _ templateApplicationScene: CPTemplateApplicationScene,
56
+ didDisconnectInterfaceController interfaceController: CPInterfaceController
57
+ ) {
58
+ os_log("CarPlay templateApplicationScene didDisconnect", log: Self.log, type: .info)
59
+ self.interfaceController = nil
60
+ CarPlayMonitor.shared.notifyEntitledDisconnect()
61
+ }
62
+
63
+ // MARK: - Override hooks
64
+
65
+ /// Build the root template shown when CarPlay attaches. Override in a
66
+ /// consumer subclass to install a richer template (e.g. `CPListTemplate`,
67
+ /// `CPMapTemplate`, `CPNowPlayingTemplate`). Default is a minimal
68
+ /// `CPInformationTemplate` confirming the app is tracking.
69
+ open func makeRootTemplate() -> CPTemplate {
70
+ let item = CPInformationItem(title: "Status", detail: "Tracking active")
71
+ return CPInformationTemplate(
72
+ title: "Driver tracking",
73
+ layout: .leading,
74
+ items: [item],
75
+ actions: []
76
+ )
77
+ }
78
+ }
@@ -0,0 +1,43 @@
1
+ import CoreBluetooth
2
+
3
+ // MARK: - CBCentralManagerDelegate (Eddystone BLE scanning)
4
+
5
+ internal final class BluetoothDelegate: NSObject, CBCentralManagerDelegate {
6
+ private weak var module: ExpoBeaconModule?
7
+
8
+ init(module: ExpoBeaconModule) {
9
+ self.module = module
10
+ }
11
+
12
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
13
+ switch central.state {
14
+ case .poweredOn:
15
+ module?.ensureBleScanRunning()
16
+ case .unauthorized:
17
+ print("[ExpoBeacon] Bluetooth authorization denied — Eddystone scanning/monitoring unavailable. " +
18
+ "Ensure NSBluetoothAlwaysUsageDescription is set in Info.plist.")
19
+ module?.handleBluetoothStateError(code: "BLUETOOTH_UNAUTHORIZED", message: "Bluetooth authorization denied — Eddystone scanning/monitoring unavailable")
20
+ module?.eddystoneScanPromise?.reject("BLUETOOTH_UNAUTHORIZED", "Bluetooth permission denied")
21
+ module?.eddystoneScanPromise = nil
22
+ case .poweredOff:
23
+ print("[ExpoBeacon] Bluetooth is powered off — Eddystone scanning/monitoring unavailable.")
24
+ module?.handleBluetoothStateError(code: "BLUETOOTH_OFF", message: "Bluetooth is powered off — Eddystone scanning/monitoring unavailable")
25
+ module?.eddystoneScanPromise?.reject("BLUETOOTH_OFF", "Bluetooth is powered off")
26
+ module?.eddystoneScanPromise = nil
27
+ default:
28
+ break
29
+ }
30
+ }
31
+
32
+ func centralManager(_ central: CBCentralManager,
33
+ didDiscover peripheral: CBPeripheral,
34
+ advertisementData: [String: Any],
35
+ rssi RSSI: NSNumber) {
36
+ module?.handleEddystoneDiscovery(advertisementData: advertisementData, rssi: RSSI)
37
+ }
38
+
39
+ func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
40
+ // State restoration: CBCentralManager was recreated by iOS after app was killed.
41
+ // Scanning will be re-started in centralManagerDidUpdateState when state is .poweredOn.
42
+ }
43
+ }
@@ -23,6 +23,12 @@ final class CarPlayMonitor {
23
23
  private var observer: NSObjectProtocol?
24
24
  private var emit: Emit?
25
25
  private var isConnected: Bool = false
26
+ /// When `true`, an authoritative source (CarPlay scene delegate, granted via the
27
+ /// `com.apple.developer.carplay-driving-task` entitlement) is providing
28
+ /// connect/disconnect events. The audio-session observer becomes a passive
29
+ /// secondary signal only — it will not emit events, to avoid duplicate
30
+ /// connect/disconnect notifications.
31
+ private var isEntitledMode: Bool = false
26
32
 
27
33
  private init() {}
28
34
 
@@ -39,8 +45,8 @@ final class CarPlayMonitor {
39
45
  forName: AVAudioSession.routeChangeNotification,
40
46
  object: nil,
41
47
  queue: .main
42
- ) { [weak self] _ in
43
- self?.handleRouteChange()
48
+ ) { [weak self] note in
49
+ self?.handleRouteChange(notification: note)
44
50
  }
45
51
  os_log("CarPlay monitoring started", log: self.log, type: .info)
46
52
  }
@@ -74,21 +80,85 @@ final class CarPlayMonitor {
74
80
  /// was suspended and `AVAudioSession.routeChangeNotification` was not delivered.
75
81
  func resyncIfNeeded() {
76
82
  queue.async { [weak self] in
77
- self?.handleRouteChange()
83
+ // Snapshot reads (initial start, region wake, explicit resync) are
84
+ // trusted: they reflect the actual current route, not a transient
85
+ // category/configuration change. Pass `nil` notification to bypass
86
+ // the route-change-reason filter.
87
+ self?.handleRouteChange(notification: nil)
78
88
  }
79
89
  }
80
90
 
81
- /// Whether `start(emit:)` has been called and `stop()` has not.
82
- /// Use to decide whether to skip a redundant start during auto-start logic.
83
- var isObserving: Bool {
84
- // Synchronous read from main queue is safe `observer` is only mutated on `queue`.
91
+ // MARK: - Entitled (Driving Task) source
92
+
93
+ /// Called by `BeaconCarPlaySceneDelegate.templateApplicationScene(_:didConnect:)`.
94
+ /// Marks the entitled path as the authoritative source and emits an immediate
95
+ /// `onCarPlayConnected` event (if not already connected from this source).
96
+ /// Subsequent route-change notifications from `AVAudioSession` are suppressed
97
+ /// for emission purposes to prevent duplicate events.
98
+ func notifyEntitledConnect(transport: String = "carplay-scene") {
99
+ queue.async { [weak self] in
100
+ guard let self = self else { return }
101
+ self.isEntitledMode = true
102
+ os_log("CarPlay scene connected (entitled source)", log: self.log, type: .info)
103
+ if !self.isConnected {
104
+ self.isConnected = true
105
+ self.emitConnected(transport: transport)
106
+ }
107
+ }
108
+ }
109
+
110
+ /// Called by `BeaconCarPlaySceneDelegate.templateApplicationScene(_:didDisconnect:)`.
111
+ /// Emits `onCarPlayDisconnected` and keeps the entitled-mode flag set so
112
+ /// subsequent audio-session events remain suppressed (the scene delegate is
113
+ /// the source of truth for the lifetime of the process).
114
+ func notifyEntitledDisconnect() {
115
+ queue.async { [weak self] in
116
+ guard let self = self else { return }
117
+ os_log("CarPlay scene disconnected (entitled source)", log: self.log, type: .info)
118
+ if self.isConnected {
119
+ self.isConnected = false
120
+ self.emitDisconnected()
121
+ }
122
+ }
123
+ }
124
+
125
+ /// Whether an entitled CarPlay scene source has notified us at least once.
126
+ /// Consumers (e.g. SLC/Visit auto-start logic) can skip cheap fallbacks
127
+ /// when the entitled real-time path is active.
128
+ var isUsingEntitledSource: Bool {
85
129
  if Thread.isMainThread {
86
- return observer != nil
130
+ return isEntitledMode
87
131
  }
88
- return queue.sync { observer != nil }
132
+ return queue.sync { isEntitledMode }
89
133
  }
90
134
 
91
- private func handleRouteChange() {
135
+ /// Process a route change. When invoked from a system notification the
136
+ /// reason is checked: only `.newDeviceAvailable` and `.oldDeviceUnavailable`
137
+ /// indicate a real device connect/disconnect. Other reasons (category change,
138
+ /// configuration change, override, etc.) are filtered to avoid spurious
139
+ /// disconnect events when the app suspends/resumes and iOS re-evaluates
140
+ /// the audio session category without the physical CarPlay link changing.
141
+ /// When `notification` is nil (initial start, explicit resync), the snapshot
142
+ /// is always trusted.
143
+ private func handleRouteChange(notification: Notification?) {
144
+ if let userInfo = notification?.userInfo,
145
+ let reasonRaw = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
146
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) {
147
+ switch reason {
148
+ case .newDeviceAvailable, .oldDeviceUnavailable:
149
+ break // real device change — proceed
150
+ default:
151
+ // Category/override/configuration changes etc. don't represent
152
+ // a CarPlay connect/disconnect. Skip to avoid spurious events.
153
+ return
154
+ }
155
+ }
156
+ // When an entitled CarPlay scene source is active it is authoritative.
157
+ // The audio-session signal is kept as a redundant secondary check but
158
+ // must NOT emit events — the scene delegate already did, or will.
159
+ if isEntitledMode {
160
+ return
161
+ }
92
162
  let (connected, transport) = Self.currentCarPlayState()
93
163
  if connected == isConnected { return }
94
164
  isConnected = connected
@@ -19,8 +19,11 @@ Pod::Spec.new do |s|
19
19
 
20
20
  s.dependency 'ExpoModulesCore'
21
21
 
22
- # Required system frameworks for iBeacon monitoring + wildcard BLE scanning
23
- s.frameworks = 'CoreLocation', 'CoreBluetooth', 'UserNotifications'
22
+ # Required system frameworks for iBeacon monitoring + wildcard BLE scanning.
23
+ # CarPlay is used by `BeaconCarPlaySceneDelegate` for the optional "Driving
24
+ # Task" entitlement integration; linking is harmless when the entitlement is
25
+ # not granted.
26
+ s.frameworks = 'CoreLocation', 'CoreBluetooth', 'UserNotifications', 'CarPlay', 'AVFoundation'
24
27
 
25
28
  # Swift/Objective-C compatibility
26
29
  s.pod_target_xcconfig = {
@@ -0,0 +1,41 @@
1
+ import Foundation
2
+
3
+ // MARK: - UserDefaults keys
4
+
5
+ internal let PAIRED_BEACONS_KEY = "expo.beacon.paired"
6
+ internal let PAIRED_EDDYSTONES_KEY = "expo.beacon.paired_eddystones"
7
+ internal let IS_MONITORING_KEY = "expo.beacon.is_monitoring"
8
+ internal let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
9
+ internal let EXIT_DISTANCE_KEY = "expo.beacon.exit_distance"
10
+ internal let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
11
+ internal let EVENT_LOGGING_ENABLED_KEY = "expo.beacon.event_logging_enabled"
12
+ internal let MIN_RSSI_KEY = "expo.beacon.min_rssi"
13
+ internal let EVENT_LEVEL_KEY = "expo.beacon.event_level"
14
+ internal let EXIT_TIMEOUT_SECONDS_KEY = "expo.beacon.exit_timeout_seconds"
15
+ internal let CARPLAY_MONITORING_ENABLED_KEY = "expo.beacon.carplay_monitoring_enabled"
16
+
17
+ // MARK: - Tuning thresholds
18
+
19
+ /// Default minimum RSSI (dBm) below which beacon readings are discarded as unreliable.
20
+ internal let DEFAULT_MIN_RSSI: Int = -85
21
+ /// Default seconds of silence after last beacon sighting before a disappearance-based exit fires.
22
+ internal let DEFAULT_EXIT_TIMEOUT_SECONDS: TimeInterval = 300.0
23
+
24
+ /// Number of consecutive in-range readings required before an enter event is emitted.
25
+ /// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
26
+ internal let ENTER_HYSTERESIS_COUNT = 1
27
+ /// Number of consecutive out-of-range readings required before an exit event is emitted.
28
+ /// IMPORTANT: Keep in sync with BeaconConstants.kt (Android).
29
+ internal let EXIT_HYSTERESIS_COUNT = 3
30
+
31
+ /// Eddystone monitoring timer interval in seconds.
32
+ internal let EDDYSTONE_MONITORING_TICK_INTERVAL: TimeInterval = 2.0
33
+ /// Maximum age (in seconds) before a beacon is considered "not recently seen".
34
+ /// Set high enough to tolerate iOS background CoreBluetooth throttling which
35
+ /// can cause 10-12 s gaps between Eddystone advertisements.
36
+ internal let EDDYSTONE_RECENTLY_SEEN_THRESHOLD: TimeInterval = 15.0
37
+ /// Minimum interval between consecutive distance event emissions per identifier.
38
+ internal let DISTANCE_EVENT_THROTTLE_INTERVAL: TimeInterval = 1.0
39
+ /// Seconds of no valid BLE readings before starting the timeout countdown.
40
+ /// Acts as a safety net when ranging cycles stop entirely (e.g. Doze mode).
41
+ internal let DISTANCE_INACTIVITY_SECONDS: TimeInterval = 60.0
@@ -0,0 +1,49 @@
1
+ import CoreLocation
2
+
3
+ extension ExpoBeaconModule {
4
+ /// Starts the shared `CarPlayMonitor` and routes its events through the
5
+ /// standard `sendLoggedEvent` pipeline (JS bridge + SQLite + API forwarder
6
+ /// + lifecycle plugin registry). Idempotent — safe to call multiple times
7
+ /// (see `CarPlayMonitor.start(emit:)` semantics).
8
+ func startCarPlayMonitoringInternal() {
9
+ CarPlayMonitor.shared.start { [weak self] eventName, payload in
10
+ self?.sendLoggedEvent(eventName, payload)
11
+ }
12
+ // Tier 2 fallback: subscribe to background-wake signals so suspended
13
+ // apps still notice CarPlay route changes that happened off-process.
14
+ // Skipped when the entitled CarPlay scene path is providing real-time
15
+ // events — that source keeps the app awake for the entire CarPlay
16
+ // session and renders SLC/Visit redundant.
17
+ if !CarPlayMonitor.shared.isUsingEntitledSource {
18
+ startCarPlayBackgroundWakes()
19
+ }
20
+ }
21
+
22
+ /// Start Significant Location Change + Visit monitoring as background-wake
23
+ /// hooks for CarPlay state reconciliation. Both are extremely low cost
24
+ /// (no continuous GPS) and reuse the existing `CLLocationManager` /
25
+ /// `LocationDelegate`. Idempotent.
26
+ func startCarPlayBackgroundWakes() {
27
+ if !CLLocationManager.significantLocationChangeMonitoringAvailable() {
28
+ return
29
+ }
30
+ locationManager.startMonitoringSignificantLocationChanges()
31
+ locationManager.startMonitoringVisits()
32
+ }
33
+
34
+ /// Stop the SLC + Visit hooks. Called from `stopCarPlayMonitoring`.
35
+ func stopCarPlayBackgroundWakes() {
36
+ locationManager.stopMonitoringSignificantLocationChanges()
37
+ locationManager.stopMonitoringVisits()
38
+ }
39
+
40
+ /// Forwarded from `LocationDelegate` for SLC and Visit callbacks.
41
+ /// Cheap reconciliation: just snapshot the audio route. The entitled
42
+ /// scene-delegate path (when active) takes precedence in `CarPlayMonitor`
43
+ /// itself.
44
+ func handleBackgroundWakeForCarPlay() {
45
+ if defaults.bool(forKey: CARPLAY_MONITORING_ENABLED_KEY) {
46
+ CarPlayMonitor.shared.resyncIfNeeded()
47
+ }
48
+ }
49
+ }