@mentra/bluetooth-sdk 3.3.0-dev.548 → 3.3.0-dev.551

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.
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.3.0"
6
- const val RELEASE_IDENTITY: String = "3.3.0-dev.548"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.548"
8
- const val SOURCE_COMMIT: String = "b0f8be74e1aae531836532a849cd402c10d2e5ae"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.548.json"
10
- const val OTA_MANIFEST_SHA256: String = "372a283a731bf08e6c5070ab867cd2b86a87ee29a993d47c1a5f5c17a5d1596e"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.551"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.551"
8
+ const val SOURCE_COMMIT: String = "f48a6c59f06665dd41924670434f47d359be0eb3"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.551.json"
10
+ const val OTA_MANIFEST_SHA256: String = "9b17306f36c2610c818c112402d1491903cfa2f4ae3ded4ffc7a358ca5e7ad2e"
11
11
  }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.3.0",
4
- "releaseIdentity": "3.3.0-dev.548",
5
- "releaseSetId": "mentra-3.3.0-dev.548",
6
- "sourceCommit": "b0f8be74e1aae531836532a849cd402c10d2e5ae",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.548.json",
8
- "otaManifestSha256": "372a283a731bf08e6c5070ab867cd2b86a87ee29a993d47c1a5f5c17a5d1596e"
4
+ "releaseIdentity": "3.3.0-dev.551",
5
+ "releaseSetId": "mentra-3.3.0-dev.551",
6
+ "sourceCommit": "f48a6c59f06665dd41924670434f47d359be0eb3",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.551.json",
8
+ "otaManifestSha256": "9b17306f36c2610c818c112402d1491903cfa2f4ae3ded4ffc7a358ca5e7ad2e"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.548\",\n \"releaseSetId\": \"mentra-3.3.0-dev.548\",\n \"sourceCommit\": \"b0f8be74e1aae531836532a849cd402c10d2e5ae\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.548.json\",\n \"otaManifestSha256\": \"372a283a731bf08e6c5070ab867cd2b86a87ee29a993d47c1a5f5c17a5d1596e\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.551\",\n \"releaseSetId\": \"mentra-3.3.0-dev.551\",\n \"sourceCommit\": \"f48a6c59f06665dd41924670434f47d359be0eb3\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.551.json\",\n \"otaManifestSha256\": \"9b17306f36c2610c818c112402d1491903cfa2f4ae3ded4ffc7a358ca5e7ad2e\"\n})\n"]}
@@ -0,0 +1,32 @@
1
+ import Foundation
2
+
3
+ /// Whether the host app lets iOS keep the glasses link working while the app is suspended.
4
+ ///
5
+ /// The SDK keeps a pending reconnection open after the glasses drop the link (for example while
6
+ /// their Bluetooth chip restarts during a software update). iOS completes it, and delivers BLE
7
+ /// events, only to apps that declare the `bluetooth-central` background mode. A Swift package
8
+ /// cannot declare it for the app, so the SDK checks it and warns instead.
9
+ enum BluetoothBackgroundMode {
10
+ static let requiredMode = "bluetooth-central"
11
+
12
+ /// Platform-independent plist check, so tests exercise it on every host.
13
+ static func declaresBluetoothCentral(_ infoDictionary: [String: Any]?) -> Bool {
14
+ guard let modes = infoDictionary?["UIBackgroundModes"] as? [String] else { return false }
15
+ return modes.contains(requiredMode)
16
+ }
17
+
18
+ static func isDeclared(in infoDictionary: [String: Any]?) -> Bool {
19
+ #if os(iOS)
20
+ return declaresBluetoothCentral(infoDictionary)
21
+ #else
22
+ // macOS apps are not suspended the same way and have no background modes.
23
+ return true
24
+ #endif
25
+ }
26
+
27
+ static let missingWarning =
28
+ "MentraBluetoothSDK: WARNING Info.plist is missing UIBackgroundModes `bluetooth-central`. "
29
+ + "While the phone is locked or the app is in the background, iOS will not reconnect to "
30
+ + "the glasses after a link drop (for example during a software update) until the app "
31
+ + "is opened. See https://docs.mentraglass.com/bluetooth-sdk/ios#background-operation"
32
+ }
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
16
16
  /// silently disable it on every pair the app touches.
17
17
  static let autoPowerOffEnabled = false
18
18
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
19
- private static let swiftPackageSdkVersion = "3.3.0-dev.548"
19
+ private static let swiftPackageSdkVersion = "3.3.0-dev.551"
20
20
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
21
21
 
22
22
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.3.0"
6
- static let releaseIdentity = "3.3.0-dev.548"
7
- static let releaseSetId = "mentra-3.3.0-dev.548"
8
- static let sourceCommit = "b0f8be74e1aae531836532a849cd402c10d2e5ae"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.548.json"
10
- static let otaManifestSha256 = "372a283a731bf08e6c5070ab867cd2b86a87ee29a993d47c1a5f5c17a5d1596e"
6
+ static let releaseIdentity = "3.3.0-dev.551"
7
+ static let releaseSetId = "mentra-3.3.0-dev.551"
8
+ static let sourceCommit = "f48a6c59f06665dd41924670434f47d359be0eb3"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.551.json"
10
+ static let otaManifestSha256 = "9b17306f36c2610c818c112402d1491903cfa2f4ae3ded4ffc7a358ca5e7ad2e"
11
11
  }
@@ -299,8 +299,22 @@ public final class MentraBluetoothSDK {
299
299
  private let wifiSessionCapabilities = WifiSessionCapabilities()
300
300
  private var configuredOtaVersionUrl: String?
301
301
 
302
+ /// Whether the app declares the `bluetooth-central` background mode. When false on iOS, the
303
+ /// glasses stay disconnected after a link drop until the user opens the app, so a software
304
+ /// update started on a locked phone stalls. Always true on macOS.
305
+ public static var supportsBackgroundReconnect: Bool {
306
+ BluetoothBackgroundMode.isDeclared(in: Bundle.main.infoDictionary)
307
+ }
308
+
309
+ private static var didWarnMissingBackgroundMode = false
310
+
302
311
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
303
312
  self.configuration = configuration
313
+ // Once per process: apps may create the SDK more than once.
314
+ if !Self.didWarnMissingBackgroundMode, !Self.supportsBackgroundReconnect {
315
+ Self.didWarnMissingBackgroundMode = true
316
+ Bridge.log(BluetoothBackgroundMode.missingWarning)
317
+ }
304
318
  analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
305
319
  bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
306
320
  Task { @MainActor [weak self] in
@@ -862,6 +862,7 @@ extension MentraLive: CBCentralManagerDelegate {
862
862
  Bridge.log("Connected to GATT server, discovering services...")
863
863
 
864
864
  self.stopConnectionTimeout()
865
+ self.clearPendingReconnectTracking()
865
866
  self.isConnecting = false
866
867
  self.connectingPeripheral = nil
867
868
  self.connectedPeripheral = peripheral
@@ -916,6 +917,7 @@ extension MentraLive: CBCentralManagerDelegate {
916
917
  return
917
918
  }
918
919
  self.connectingPeripheral = nil
920
+ self.clearPendingReconnectTracking()
919
921
  Bridge.log("LIVE: Disconnected from GATT server")
920
922
 
921
923
  self.isConnecting = false
@@ -945,8 +947,9 @@ extension MentraLive: CBCentralManagerDelegate {
945
947
  return
946
948
  }
947
949
 
948
- // Attempt reconnection if not killed
949
- if !self.isKilled {
950
+ // Prefer a pending direct reconnect: it is the only path iOS completes while the
951
+ // app is suspended. Fall back to the scan backoff when it does not apply.
952
+ if !self.isKilled, !self.reconnectDirectly(to: peripheral) {
950
953
  self.handleReconnection()
951
954
  }
952
955
  }
@@ -966,6 +969,7 @@ extension MentraLive: CBCentralManagerDelegate {
966
969
  Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
967
970
 
968
971
  self.stopConnectionTimeout()
972
+ self.clearPendingReconnectTracking()
969
973
  self.isConnecting = false
970
974
  self.closeL2capFileChannel()
971
975
  self.connectedPeripheral = nil
@@ -1646,6 +1650,10 @@ class MentraLive: NSObject, SGCManager {
1646
1650
  private static let glassesMediaVolumeTimeoutSec: TimeInterval = 2.0
1647
1651
 
1648
1652
  private var connectionTimeoutTimer: Timer?
1653
+ /// Peripheral of an armed pending reconnect (see `reconnectDirectly`).
1654
+ private var pendingReconnectPeripheral: CBPeripheral?
1655
+ private var pendingReconnect = MentraLivePendingReconnect()
1656
+ private var pendingReconnectSettleWorkItem: DispatchWorkItem?
1649
1657
  private var reconnectionWorkItem: DispatchWorkItem?
1650
1658
  private var requiresAncs = true
1651
1659
 
@@ -1713,6 +1721,7 @@ class MentraLive: NSObject, SGCManager {
1713
1721
 
1714
1722
  // clear the saved device name:
1715
1723
  UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
1724
+ cancelPendingReconnect(reason: "discovery")
1716
1725
 
1717
1726
  manualDiscoveryActive = true
1718
1727
  startScan()
@@ -1725,6 +1734,7 @@ class MentraLive: NSObject, SGCManager {
1725
1734
  }
1726
1735
 
1727
1736
  private func enterPairingYield(windowMs: Int) {
1737
+ cancelPendingReconnect(reason: "pairing_yield")
1728
1738
  pairingYieldActive = true
1729
1739
  pairingYieldAwaitingReclaim = false
1730
1740
  pairingYieldEndWorkItem?.cancel()
@@ -1805,6 +1815,7 @@ class MentraLive: NSObject, SGCManager {
1805
1815
  return
1806
1816
  }
1807
1817
  Bridge.log("connectById: \(deviceName)")
1818
+ cancelPendingReconnect(reason: "connect_by_id")
1808
1819
  // Save the device name for future reconnection
1809
1820
  UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
1810
1821
 
@@ -2357,15 +2368,59 @@ class MentraLive: NSObject, SGCManager {
2357
2368
  }
2358
2369
  Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
2359
2370
 
2371
+ // A timed attempt supersedes any pending reconnect: withdraw one aimed at other
2372
+ // glasses, and drop tracking for this one so its settle timer cannot touch this attempt.
2373
+ if let pending = pendingReconnectPeripheral, pending !== peripheral {
2374
+ cancelPendingReconnect(reason: "superseded")
2375
+ } else {
2376
+ clearPendingReconnectTracking()
2377
+ }
2378
+
2360
2379
  isConnecting = true
2361
2380
  updateConnectionState(ConnTypes.CONNECTING)
2362
2381
  connectingPeripheral = peripheral
2363
2382
  connectedPeripheral = peripheral
2364
- peripheral.delegate = self
2365
2383
 
2366
2384
  // Set connection timeout
2367
2385
  startConnectionTimeout()
2368
2386
 
2387
+ issueConnect(peripheral)
2388
+ }
2389
+
2390
+ /// Re-arms a pending connection to the glasses that just dropped. Unlike `connectToDevice`,
2391
+ /// it sets no timeout: CoreBluetooth keeps the request open until the glasses advertise
2392
+ /// again (for example after a BES firmware reset), completes it in the background and wakes
2393
+ /// the app. Discovery, connect-by-id, pairing yield and `destroy()` cancel it through
2394
+ /// `cancelPendingReconnect`. Returns false when the policy does not apply, so the caller can
2395
+ /// fall back to scanning.
2396
+ private func reconnectDirectly(to peripheral: CBPeripheral) -> Bool {
2397
+ guard let centralManager,
2398
+ MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
2399
+ isKilled: isKilled,
2400
+ pairingYieldActive: pairingYieldActive,
2401
+ bluetoothPoweredOn: centralManager.state == .poweredOn,
2402
+ peripheralName: peripheral.name,
2403
+ savedDeviceName: UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
2404
+ )
2405
+ else {
2406
+ return false
2407
+ }
2408
+ Bridge.log(
2409
+ "LIVE: Pending direct reconnect to \(peripheral.identifier.uuidString) (no timeout, completes in background)"
2410
+ )
2411
+ isConnecting = true
2412
+ updateConnectionState(ConnTypes.CONNECTING)
2413
+ connectingPeripheral = peripheral
2414
+ connectedPeripheral = peripheral
2415
+ pendingReconnectPeripheral = peripheral
2416
+ issueConnect(peripheral)
2417
+ schedulePendingReconnectSettle(attempt: pendingReconnect.arm())
2418
+ return true
2419
+ }
2420
+
2421
+ /// The single CoreBluetooth connect call, shared by user-initiated and pending reconnects.
2422
+ private func issueConnect(_ peripheral: CBPeripheral) {
2423
+ peripheral.delegate = self
2369
2424
  #if os(macOS)
2370
2425
  centralManager?.connect(peripheral, options: nil)
2371
2426
  #else
@@ -2380,6 +2435,58 @@ class MentraLive: NSObject, SGCManager {
2380
2435
  #endif
2381
2436
  }
2382
2437
 
2438
+ /// A pending connect never times out, so the glasses may simply never return (battery
2439
+ /// dead, left behind). After the old scan backoff's span, report DISCONNECTED like that
2440
+ /// backoff did, but keep the request armed: iOS still completes it whenever the glasses
2441
+ /// advertise, and `didConnect` accepts it through `connectingPeripheral`.
2442
+ private func schedulePendingReconnectSettle(attempt: Int) {
2443
+ pendingReconnectSettleWorkItem?.cancel()
2444
+ let work = DispatchWorkItem { [weak self] in
2445
+ // Only the attempt that armed this timer may settle state; a failure, fallback
2446
+ // connect or re-arm clears or replaces it.
2447
+ guard let self, self.pendingReconnect.owns(attempt),
2448
+ self.connectionState != ConnTypes.CONNECTED
2449
+ else { return }
2450
+ Bridge.log("LIVE: Glasses still unreachable; reporting disconnected, pending reconnect stays armed")
2451
+ self.pendingReconnectSettleWorkItem = nil
2452
+ self.isConnecting = false
2453
+ self.connectedPeripheral = nil
2454
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
2455
+ }
2456
+ pendingReconnectSettleWorkItem = work
2457
+ DispatchQueue.main.asyncAfter(
2458
+ deadline: .now() + .milliseconds(MentraLiveConnectionAttemptPolicy.pendingReconnectSettleMs),
2459
+ execute: work
2460
+ )
2461
+ }
2462
+
2463
+ /// Stops tracking a pending reconnect after it connected; no CoreBluetooth call needed.
2464
+ private func clearPendingReconnectTracking() {
2465
+ pendingReconnectSettleWorkItem?.cancel()
2466
+ pendingReconnectSettleWorkItem = nil
2467
+ pendingReconnect.clear()
2468
+ pendingReconnectPeripheral = nil
2469
+ }
2470
+
2471
+ /// Withdraws a pending reconnect before the user retargets (discovery, connect-by-id,
2472
+ /// pairing yield) or tears down, so iOS cannot later complete it to the previous glasses.
2473
+ private func cancelPendingReconnect(reason: String) {
2474
+ guard let peripheral = pendingReconnectPeripheral else {
2475
+ clearPendingReconnectTracking()
2476
+ return
2477
+ }
2478
+ clearPendingReconnectTracking()
2479
+ Bridge.log("LIVE: Cancelling pending reconnect to \(peripheral.identifier.uuidString) (\(reason))")
2480
+ if connectingPeripheral === peripheral {
2481
+ connectingPeripheral = nil
2482
+ }
2483
+ if connectedPeripheral === peripheral, connectionState != ConnTypes.CONNECTED {
2484
+ connectedPeripheral = nil
2485
+ }
2486
+ isConnecting = false
2487
+ centralManager?.cancelPeripheralConnection(peripheral)
2488
+ }
2489
+
2383
2490
  /// Opt the firmware into ANCS only after iOS has authorized this accessory.
2384
2491
  /// Old firmware safely ignores the command, while new firmware stays inert
2385
2492
  /// for old mobile clients that never send it.
@@ -5897,6 +6004,7 @@ class MentraLive: NSObject, SGCManager {
5897
6004
  closeL2capFileChannel()
5898
6005
 
5899
6006
  // Disconnect BLE
6007
+ cancelPendingReconnect(reason: "destroy")
5900
6008
  if let peripheral = connectedPeripheral {
5901
6009
  centralManager?.cancelPeripheralConnection(peripheral)
5902
6010
  }
@@ -61,4 +61,51 @@ enum MentraLiveConnectionAttemptPolicy {
61
61
  ) -> Bool {
62
62
  !pairingYieldActive && matchesActiveAttempt
63
63
  }
64
+
65
+ /// How long a pending reconnect reports CONNECTING before settling to DISCONNECTED. Matches
66
+ /// the span of the scan backoff it replaces (1+2+4+8+16 s, then 30 s × 5 attempts).
67
+ static let pendingReconnectSettleMs = 181_000
68
+
69
+ /// Whether an unexpected link loss should re-arm a pending connection to the same
70
+ /// peripheral. iOS completes a pending `connect` whenever the glasses advertise again,
71
+ /// even while the app is suspended (phone locked), and wakes the app to handle it. A scan
72
+ /// without service filters returns nothing in the background, and the backoff timers that
73
+ /// drive it stop while the app is suspended, so the scan path alone never reconnects there.
74
+ static func shouldReconnectDirectly(
75
+ isKilled: Bool,
76
+ pairingYieldActive: Bool,
77
+ bluetoothPoweredOn: Bool,
78
+ peripheralName: String?,
79
+ savedDeviceName: String?
80
+ ) -> Bool {
81
+ guard !isKilled, !pairingYieldActive, bluetoothPoweredOn,
82
+ let peripheralName, !peripheralName.isEmpty,
83
+ let savedDeviceName
84
+ else {
85
+ return false
86
+ }
87
+ return peripheralName == savedDeviceName
88
+ }
89
+ }
90
+
91
+ /// Ownership of the one armed pending reconnect. Each arm returns a new attempt number, so a
92
+ /// timer captured for an earlier attempt can tell it no longer owns the connection state after
93
+ /// a failure, a fallback connect or a re-arm.
94
+ struct MentraLivePendingReconnect {
95
+ private(set) var attempt = 0
96
+ private(set) var isArmed = false
97
+
98
+ mutating func arm() -> Int {
99
+ attempt += 1
100
+ isArmed = true
101
+ return attempt
102
+ }
103
+
104
+ mutating func clear() {
105
+ isArmed = false
106
+ }
107
+
108
+ func owns(_ candidate: Int) -> Bool {
109
+ isArmed && candidate == attempt
110
+ }
64
111
  }
@@ -0,0 +1,32 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothBackgroundModeTests: XCTestCase {
5
+ func testDetectsDeclaredBluetoothCentral() {
6
+ XCTAssertTrue(
7
+ BluetoothBackgroundMode.declaresBluetoothCentral(
8
+ ["UIBackgroundModes": ["audio", "bluetooth-central"]]
9
+ )
10
+ )
11
+ }
12
+
13
+ func testReportsMissingOrMalformedBackgroundModes() {
14
+ XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(nil))
15
+ XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral([:]))
16
+ XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": ["audio"]]))
17
+ XCTAssertFalse(
18
+ BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": "bluetooth-central"])
19
+ )
20
+ }
21
+
22
+ func testMacOSHasNoBackgroundModeRequirement() {
23
+ #if os(macOS)
24
+ XCTAssertTrue(BluetoothBackgroundMode.isDeclared(in: nil))
25
+ #endif
26
+ }
27
+
28
+ func testWarningPointsToTheDocs() {
29
+ XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("bluetooth-central"))
30
+ XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("#background-operation"))
31
+ }
32
+ }
@@ -0,0 +1,58 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class MentraLiveReconnectPolicyTests: XCTestCase {
5
+ private func decide(
6
+ isKilled: Bool = false,
7
+ pairingYieldActive: Bool = false,
8
+ bluetoothPoweredOn: Bool = true,
9
+ peripheralName: String? = "Mentra_Live_D910",
10
+ savedDeviceName: String? = "Mentra_Live_D910"
11
+ ) -> Bool {
12
+ MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
13
+ isKilled: isKilled,
14
+ pairingYieldActive: pairingYieldActive,
15
+ bluetoothPoweredOn: bluetoothPoweredOn,
16
+ peripheralName: peripheralName,
17
+ savedDeviceName: savedDeviceName
18
+ )
19
+ }
20
+
21
+ func testRemembersLostGlassesWithPendingConnect() {
22
+ XCTAssertTrue(decide())
23
+ }
24
+
25
+ func testDoesNotReconnectAfterUserDisconnectOrKill() {
26
+ XCTAssertFalse(decide(isKilled: true))
27
+ }
28
+
29
+ func testYieldsToAnotherPhonePairing() {
30
+ XCTAssertFalse(decide(pairingYieldActive: true))
31
+ }
32
+
33
+ func testFallsBackWhenBluetoothIsOff() {
34
+ XCTAssertFalse(decide(bluetoothPoweredOn: false))
35
+ }
36
+
37
+ func testDoesNotReconnectForgottenOrDifferentGlasses() {
38
+ XCTAssertFalse(decide(savedDeviceName: nil))
39
+ XCTAssertFalse(decide(savedDeviceName: "Mentra_Live_023B"))
40
+ XCTAssertFalse(decide(peripheralName: nil))
41
+ XCTAssertFalse(decide(peripheralName: "", savedDeviceName: ""))
42
+ }
43
+
44
+ func testFailureThenFallbackLeavesTheStaleSettleTimerPowerless() {
45
+ var pending = MentraLivePendingReconnect()
46
+ let first = pending.arm()
47
+ XCTAssertTrue(pending.owns(first))
48
+
49
+ // didFailToConnect or a fallback connectToDevice clears tracking.
50
+ pending.clear()
51
+ XCTAssertFalse(pending.owns(first))
52
+
53
+ // A later re-arm does not revive the earlier attempt's timer.
54
+ let second = pending.arm()
55
+ XCTAssertFalse(pending.owns(first))
56
+ XCTAssertTrue(pending.owns(second))
57
+ }
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.3.0-dev.548",
3
+ "version": "3.3.0-dev.551",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -114,7 +114,7 @@
114
114
  "registry": "https://registry.npmjs.org/"
115
115
  },
116
116
  "dependencies": {
117
- "@mentra/cloud-protocol": "3.3.0-dev.548"
117
+ "@mentra/cloud-protocol": "3.3.0-dev.551"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -2,5 +2,7 @@ import { IOSConfig, type ConfigPlugin } from "expo/config-plugins";
2
2
  import { type BluetoothSdkPluginProps } from "./index";
3
3
  export declare const INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
4
4
  export declare const INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
5
+ /** Lets iOS complete the SDK's pending reconnect, and deliver BLE events, while the app is suspended. */
6
+ export declare const IOS_BLUETOOTH_BACKGROUND_MODE = "bluetooth-central";
5
7
  export declare function applyBluetoothSdkInfoPlist(infoPlist: IOSConfig.InfoPlist, props: BluetoothSdkPluginProps | undefined): IOSConfig.InfoPlist;
6
8
  export declare const withIosConfiguration: ConfigPlugin<BluetoothSdkPluginProps>;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.withIosConfiguration = exports.INFO_SDK_VERSION = exports.INFO_ANALYTICS_ENVIRONMENT = void 0;
6
+ exports.withIosConfiguration = exports.IOS_BLUETOOTH_BACKGROUND_MODE = exports.INFO_SDK_VERSION = exports.INFO_ANALYTICS_ENVIRONMENT = void 0;
7
7
  exports.applyBluetoothSdkInfoPlist = applyBluetoothSdkInfoPlist;
8
8
  const child_process_1 = require("child_process");
9
9
  const fs_1 = __importDefault(require("fs"));
@@ -17,6 +17,8 @@ exports.INFO_ANALYTICS_ENVIRONMENT = "MentraBluetoothSdkAnalyticsEnvironment";
17
17
  exports.INFO_SDK_VERSION = "MentraBluetoothSdkVersion";
18
18
  const STALE_INFO_POSTHOG_API_KEY = "MentraBluetoothSdkPostHogApiKey";
19
19
  const STALE_INFO_POSTHOG_HOST = "MentraBluetoothSdkPostHogHost";
20
+ /** Lets iOS complete the SDK's pending reconnect, and deliver BLE events, while the app is suspended. */
21
+ exports.IOS_BLUETOOTH_BACKGROUND_MODE = "bluetooth-central";
20
22
  const ensureBluetoothSdkExpoAdapterPodEnv = (podfile) => {
21
23
  if (podfile.includes(BLUETOOTH_SDK_EXPO_ADAPTER_ENV)) {
22
24
  return podfile;
@@ -70,6 +72,15 @@ function applyBluetoothSdkInfoPlist(infoPlist, props) {
70
72
  delete infoPlist[STALE_INFO_POSTHOG_API_KEY];
71
73
  delete infoPlist[STALE_INFO_POSTHOG_HOST];
72
74
  infoPlist[exports.INFO_SDK_VERSION] = sdkVersion;
75
+ // Merge, never replace: apps may also declare audio, location or other modes.
76
+ const declaredModes = infoPlist.UIBackgroundModes;
77
+ if (declaredModes !== undefined && !Array.isArray(declaredModes)) {
78
+ throw new Error("ios.infoPlist.UIBackgroundModes must be an array of background mode strings");
79
+ }
80
+ const backgroundModes = declaredModes ?? [];
81
+ if (!backgroundModes.includes(exports.IOS_BLUETOOTH_BACKGROUND_MODE)) {
82
+ infoPlist.UIBackgroundModes = [...backgroundModes, exports.IOS_BLUETOOTH_BACKGROUND_MODE];
83
+ }
73
84
  if (analytics.disabled !== undefined) {
74
85
  infoPlist[INFO_ANALYTICS_DISABLED] = analytics.disabled;
75
86
  }
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.3.0",
15
- "releaseIdentity": "3.3.0-dev.548",
16
- "releaseSetId": "mentra-3.3.0-dev.548",
17
- "sourceCommit": "b0f8be74e1aae531836532a849cd402c10d2e5ae",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.548.json",
19
- "otaManifestSha256": "372a283a731bf08e6c5070ab867cd2b86a87ee29a993d47c1a5f5c17a5d1596e"
15
+ "releaseIdentity": "3.3.0-dev.551",
16
+ "releaseSetId": "mentra-3.3.0-dev.551",
17
+ "sourceCommit": "f48a6c59f06665dd41924670434f47d359be0eb3",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.551.json",
19
+ "otaManifestSha256": "9b17306f36c2610c818c112402d1491903cfa2f4ae3ded4ffc7a358ca5e7ad2e"
20
20
  })