@mentra/bluetooth-sdk 3.2.0-dev.193 → 3.2.0-dev.200

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 (52) hide show
  1. package/README.md +30 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
  12. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  18. package/build/BluetoothSdk.types.d.ts +7 -0
  19. package/build/BluetoothSdk.types.d.ts.map +1 -1
  20. package/build/BluetoothSdk.types.js.map +1 -1
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js +1 -0
  23. package/build/_private/BluetoothSdkModule.js.map +1 -1
  24. package/build/generated/releaseMetadata.js +5 -5
  25. package/build/generated/releaseMetadata.js.map +1 -1
  26. package/build/react/useGlassesConnection.d.ts +5 -0
  27. package/build/react/useGlassesConnection.d.ts.map +1 -1
  28. package/build/react/useGlassesConnection.js +2 -2
  29. package/build/react/useGlassesConnection.js.map +1 -1
  30. package/build/react/useMentraBluetooth.d.ts +5 -0
  31. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  32. package/build/react/useMentraBluetooth.js +1 -0
  33. package/build/react/useMentraBluetooth.js.map +1 -1
  34. package/ios/BluetoothSdkModule.swift +2 -2
  35. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  36. package/ios/Source/Bridge.swift +15 -1
  37. package/ios/Source/DeviceManager.swift +11 -5
  38. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  39. package/ios/Source/MentraBluetoothSDK.swift +42 -9
  40. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  41. package/ios/Source/sgcs/MentraLive.swift +29 -5
  42. package/ios/Source/status/DeviceStatus.swift +2 -3
  43. package/ios/Source/types/DeviceModels.swift +9 -1
  44. package/ios/Tests/MentraLiveConnectionOptionsTests.swift +30 -0
  45. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  46. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  47. package/package.json +1 -1
  48. package/src/BluetoothSdk.types.ts +7 -0
  49. package/src/_private/BluetoothSdkModule.ts +1 -0
  50. package/src/generated/releaseMetadata.ts +5 -5
  51. package/src/react/useGlassesConnection.ts +7 -2
  52. package/src/react/useMentraBluetooth.ts +6 -0
@@ -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.2.0"
6
- static let releaseIdentity = "3.2.0-dev.193"
7
- static let releaseSetId = "mentra-3.2.0-dev.193"
8
- static let sourceCommit = "ca3b4bd4109d80253a3369cbf1ca8f9e86236fba"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.193.json"
10
- static let otaManifestSha256 = "b5201e1a9d926df220adfbf26cba934948d3f09ed81453c3600770a7618dd997"
6
+ static let releaseIdentity = "3.2.0-dev.200"
7
+ static let releaseSetId = "mentra-3.2.0-dev.200"
8
+ static let sourceCommit = "1cdd911f596eba7b2b2f0e9ba5655c7ff16b5362"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.200.json"
10
+ static let otaManifestSha256 = "f0b5cca2fa4d1d43acf5933fb4de629819d36a145a162c763f4faa27a7c8ff79"
11
11
  }
@@ -101,6 +101,17 @@ private final class PendingVideoRecordingRequest {
101
101
  }
102
102
  }
103
103
 
104
+ @MainActor
105
+ private final class PendingVersionInfoRequest {
106
+ let pending: PendingResponse<VersionInfoResult>
107
+ let accumulator: VersionInfoResponseAccumulator
108
+
109
+ init(pending: PendingResponse<VersionInfoResult>, requestId: String) {
110
+ self.pending = pending
111
+ accumulator = VersionInfoResponseAccumulator(expectedRequestId: requestId)
112
+ }
113
+ }
114
+
104
115
  // seq records send order (assigned and handed to the BLE queue in a single
105
116
  // MainActor turn) so that an id-carrying status for a newer start can
106
117
  // identify which older in-flight starts it preempted.
@@ -191,6 +202,7 @@ public final class MentraBluetoothSDK {
191
202
  private var bluetoothAvailabilityListenerId: UUID?
192
203
  private var shouldRestoreGlassesOnBluetoothRestore = false
193
204
  private var shouldRestoreControllerOnBluetoothRestore = false
205
+ private var requiresAncsForBluetoothRestore = true
194
206
  private var bridgeEventSinkId: String?
195
207
  private var storeListenerId: String?
196
208
  private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address", "project_name"]
@@ -218,7 +230,7 @@ public final class MentraBluetoothSDK {
218
230
  private var wifiScanTask: Task<[WifiScanResult], Error>?
219
231
  private var pendingWifiStatus: PendingWifiStatusRequest?
220
232
  private var pendingHotspotStatus: PendingHotspotStatusRequest?
221
- private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
233
+ private var pendingVersionInfo: PendingVersionInfoRequest?
222
234
  private var configuredOtaVersionUrl: String?
223
235
 
224
236
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
@@ -408,6 +420,9 @@ public final class MentraBluetoothSDK {
408
420
  try BluetoothAvailability.shared.requirePoweredOn(operation: "connect to glasses")
409
421
  }
410
422
  let isController = ControllerTypes.ALL.contains(device.model.deviceType)
423
+ if !isController {
424
+ requiresAncsForBluetoothRestore = options.requiresAncs
425
+ }
411
426
  if options.cancelExistingConnectionAttempt {
412
427
  if isController {
413
428
  DeviceManager.shared.disconnectController()
@@ -427,11 +442,12 @@ public final class MentraBluetoothSDK {
427
442
  )
428
443
  }
429
444
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_wearable", device.model.deviceType)
430
- DeviceManager.shared.connectByName(device.name)
445
+ DeviceManager.shared.connectByName(device.name, requiresAncs: options.requiresAncs)
431
446
  }
432
447
 
433
448
  public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
434
449
  clearBluetoothRestoreIntent()
450
+ requiresAncsForBluetoothRestore = options.requiresAncs
435
451
  guard let device = currentDefaultDevice() else {
436
452
  throw BluetoothSdkError(
437
453
  code: "default_device_missing",
@@ -444,7 +460,7 @@ public final class MentraBluetoothSDK {
444
460
  if options.cancelExistingConnectionAttempt {
445
461
  cancelConnectionAttempt()
446
462
  }
447
- DeviceManager.shared.connectDefault()
463
+ DeviceManager.shared.connectDefault(requiresAncs: options.requiresAncs)
448
464
  }
449
465
 
450
466
  public func cancelConnectionAttempt() {
@@ -1237,17 +1253,19 @@ public final class MentraBluetoothSDK {
1237
1253
  message: "A version info request is already waiting for a glasses response."
1238
1254
  )
1239
1255
  }
1256
+ let requestId = UUID().uuidString
1240
1257
  let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1241
- pendingVersionInfo = pending
1242
- DeviceManager.shared.requestVersionInfo()
1258
+ let request = PendingVersionInfoRequest(pending: pending, requestId: requestId)
1259
+ pendingVersionInfo = request
1260
+ DeviceManager.shared.requestVersionInfo(requestId: requestId)
1243
1261
  do {
1244
1262
  let status = try await pending.wait()
1245
- if pendingVersionInfo === pending {
1263
+ if pendingVersionInfo === request {
1246
1264
  pendingVersionInfo = nil
1247
1265
  }
1248
1266
  return status
1249
1267
  } catch {
1250
- if pendingVersionInfo === pending {
1268
+ if pendingVersionInfo === request {
1251
1269
  pendingVersionInfo = nil
1252
1270
  }
1253
1271
  throw error
@@ -1552,7 +1570,9 @@ public final class MentraBluetoothSDK {
1552
1570
  clearBluetoothRestoreIntent()
1553
1571
 
1554
1572
  if restoreGlasses, !glassesStatus.connected, glassesStatus.connectionState == .disconnected {
1555
- DeviceManager.shared.connectDefault() // also restores the controller
1573
+ DeviceManager.shared.connectDefault(
1574
+ requiresAncs: requiresAncsForBluetoothRestore
1575
+ ) // also restores the controller
1556
1576
  } else if restoreController, !glassesStatus.controllerConnected {
1557
1577
  DeviceManager.shared.connectDefaultController()
1558
1578
  }
@@ -2168,6 +2188,19 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2168
2188
  activeSession.onComplete(activeSession.latestResults)
2169
2189
  }
2170
2190
 
2191
+ private func handleVersionInfoForRequest(_ data: [String: Any]) {
2192
+ guard let request = pendingVersionInfo else { return }
2193
+ switch request.accumulator.accept(data) {
2194
+ case .ignored:
2195
+ break
2196
+ case .waiting:
2197
+ break
2198
+ case let .complete(result):
2199
+ pendingVersionInfo = nil
2200
+ request.pending.resolve(result)
2201
+ }
2202
+ }
2203
+
2171
2204
  private func dispatchBridgeEvent(_ eventName: String, _ data: [String: Any]) {
2172
2205
  switch eventName {
2173
2206
  case "log":
@@ -2300,7 +2333,7 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2300
2333
  delegate?.mentraBluetoothSDK(self, didReceive: .settingsAck(event))
2301
2334
  case "version_info":
2302
2335
  let event = VersionInfoResult(values: data)
2303
- pendingVersionInfo?.resolve(event)
2336
+ handleVersionInfoForRequest(data)
2304
2337
  delegate?.mentraBluetoothSDK(self, didReceive: .versionInfo(event))
2305
2338
  case "compatible_glasses_search_stop":
2306
2339
  delegate?.mentraBluetoothSDK(self, didStopScan: .completed)
@@ -0,0 +1,101 @@
1
+ import CoreFoundation
2
+ import Foundation
3
+
4
+ enum VersionInfoAccumulatorOutcome {
5
+ case ignored
6
+ case waiting
7
+ case complete(VersionInfoResult)
8
+ }
9
+
10
+ /// One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary.
11
+ final class VersionInfoResponseAccumulator {
12
+ static let responseChunkKey = "_responseChunk"
13
+ static let responseRequestIdKey = "_responseRequestId"
14
+ static let responseIndexKey = "_responseChunkIndex"
15
+ static let responseCountKey = "_responseChunkCount"
16
+ static let responseFinalKey = "_responseFinal"
17
+ static let responseSidKey = "_responseSid"
18
+ private static let modernKeys = [responseRequestIdKey, responseIndexKey, responseCountKey, responseFinalKey]
19
+
20
+ private let expectedRequestId: String
21
+ private var values: [String: Any] = [:]
22
+ private var chunks: [Int: [String: Any]] = [:]
23
+ private var count: Int?
24
+ private var sid: String?
25
+ private var legacyStarted = false
26
+ private var completed = false
27
+
28
+ init(expectedRequestId: String) {
29
+ self.expectedRequestId = expectedRequestId
30
+ }
31
+
32
+ func accept(_ event: [String: Any]) -> VersionInfoAccumulatorOutcome {
33
+ guard !completed else { return .ignored }
34
+ if Self.modernKeys.contains(where: { event[$0] != nil }) {
35
+ guard event[Self.responseRequestIdKey] as? String == expectedRequestId,
36
+ let index = integer(event[Self.responseIndexKey]),
37
+ let total = integer(event[Self.responseCountKey]),
38
+ let final = event[Self.responseFinalKey] as? NSNumber,
39
+ CFGetTypeID(final) == CFBooleanGetTypeID(),
40
+ let process = event[Self.responseSidKey] as? String, !process.isEmpty,
41
+ (1 ... 16).contains(total), (1 ... total).contains(index),
42
+ final.boolValue == (index == total)
43
+ else { return .ignored }
44
+ if let count, count != total || sid != process { return .ignored }
45
+ if count == nil {
46
+ values.removeAll()
47
+ count = total
48
+ sid = process
49
+ }
50
+ chunks[index] = event
51
+ guard chunks.count == total else { return .waiting }
52
+ for index in 1 ... total {
53
+ merge(chunks[index]!)
54
+ }
55
+ return finish()
56
+ }
57
+ guard count == nil else { return .ignored }
58
+ switch event[Self.responseChunkKey] as? String ?? "version_info" {
59
+ case "version_info":
60
+ values.removeAll()
61
+ merge(event)
62
+ return finish()
63
+ case "version_info_1":
64
+ values.removeAll()
65
+ legacyStarted = true
66
+ merge(event)
67
+ case "version_info_2":
68
+ guard legacyStarted else { return .ignored }
69
+ merge(event)
70
+ case "version_info_3":
71
+ guard legacyStarted else { return .ignored }
72
+ merge(event)
73
+ return finish()
74
+ default: return .ignored
75
+ }
76
+ // Silence is never evidence of completion. The request's normal deadline handles loss.
77
+ return .waiting
78
+ }
79
+
80
+ private func finish() -> VersionInfoAccumulatorOutcome {
81
+ completed = true
82
+ return .complete(VersionInfoResult(values: values))
83
+ }
84
+
85
+ private func merge(_ event: [String: Any]) {
86
+ for (key, value) in event where !key.hasPrefix("_response") && key != "type" {
87
+ if let string = value as? String, string.isEmpty { continue }
88
+ values[key] = value
89
+ }
90
+ }
91
+
92
+ private func integer(_ value: Any?) -> Int? {
93
+ guard let number = value as? NSNumber,
94
+ CFGetTypeID(number) != CFBooleanGetTypeID(),
95
+ number.doubleValue.isFinite,
96
+ number.doubleValue >= 1, number.doubleValue <= 16,
97
+ number.doubleValue == Double(number.intValue)
98
+ else { return nil }
99
+ return number.intValue
100
+ }
101
+ }
@@ -1384,6 +1384,17 @@ enum MentraLiveConnectionState {
1384
1384
  case connected
1385
1385
  }
1386
1386
 
1387
+ enum MentraLiveConnectionOptions {
1388
+ static func coreBluetoothOptions(requiresAncs: Bool) -> [String: Any]? {
1389
+ #if os(macOS)
1390
+ return nil
1391
+ #else
1392
+ guard requiresAncs else { return nil }
1393
+ return [CBConnectPeripheralOptionRequiresANCS: true]
1394
+ #endif
1395
+ }
1396
+ }
1397
+
1387
1398
  /// Type aliases for compatibility
1388
1399
  typealias JSONObject = [String: Any]
1389
1400
 
@@ -1743,6 +1754,7 @@ class MentraLive: NSObject, SGCManager {
1743
1754
 
1744
1755
  private var connectionTimeoutTimer: Timer?
1745
1756
  private var reconnectionWorkItem: DispatchWorkItem?
1757
+ private var requiresAncs = true
1746
1758
 
1747
1759
  // MARK: - Initialization
1748
1760
 
@@ -1775,6 +1787,10 @@ class MentraLive: NSObject, SGCManager {
1775
1787
  destroy()
1776
1788
  }
1777
1789
 
1790
+ func setRequiresAncs(_ requiresAncs: Bool) {
1791
+ self.requiresAncs = requiresAncs
1792
+ }
1793
+
1778
1794
  // MARK: - React Native Interface
1779
1795
 
1780
1796
  private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
@@ -2448,11 +2464,12 @@ class MentraLive: NSObject, SGCManager {
2448
2464
  centralManager?.connect(peripheral, options: nil)
2449
2465
  #else
2450
2466
  // ANCS is hosted by iOS and is only exposed to authorized accessories.
2451
- // Requiring it at connect time lets the system complete that authorization
2452
- // flow before the glasses subscribe to the ANCS characteristics.
2467
+ // The default requirement lets the system complete that authorization flow
2468
+ // before the glasses subscribe; apps that do not relay notifications can opt out.
2469
+ Bridge.log("LIVE: ANCS connection requirement \(requiresAncs ? "enabled" : "disabled")")
2453
2470
  centralManager?.connect(
2454
2471
  peripheral,
2455
- options: [CBConnectPeripheralOptionRequiresANCS: true]
2472
+ options: MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: requiresAncs)
2456
2473
  )
2457
2474
  #endif
2458
2475
  }
@@ -3140,7 +3157,7 @@ class MentraLive: NSObject, SGCManager {
3140
3157
  maybeSendWireHandshake()
3141
3158
  handleGlassesSessionId(json)
3142
3159
 
3143
- Bridge.sendVersionInfo(fields)
3160
+ Bridge.sendVersionInfo(fields, responseChunk: type)
3144
3161
  } else {
3145
3162
  Bridge.log("Unhandled message type: \(type)")
3146
3163
  }
@@ -5009,7 +5026,14 @@ class MentraLive: NSObject, SGCManager {
5009
5026
  }
5010
5027
 
5011
5028
  func requestVersionInfo() {
5012
- let json: [String: Any] = ["type": "request_version"]
5029
+ requestVersionInfo(requestId: nil)
5030
+ }
5031
+
5032
+ func requestVersionInfo(requestId: String?) {
5033
+ var json: [String: Any] = ["type": "request_version"]
5034
+ if let requestId {
5035
+ json["request_id"] = requestId
5036
+ }
5013
5037
  sendJson(json)
5014
5038
  }
5015
5039
 
@@ -420,9 +420,8 @@ public struct VersionInfoResult: CustomStringConvertible {
420
420
  if let systemTimeMs {
421
421
  values["systemTimeMs"] = systemTimeMs
422
422
  }
423
- // Only when known. requestVersionInfo() resolves from ANY version_info chunk, and only
424
- // chunk 1 carries package_name emitting "" from a chunk-3 resolution would overwrite a
425
- // known identity with "absent", which the OTA guard reads as stock.
423
+ // Only when known: per-chunk events may omit package identity. Emitting an empty value
424
+ // would overwrite a known identity, which the OTA guard uses to identify stock.
426
425
  if !packageName.isEmpty {
427
426
  values["packageName"] = packageName
428
427
  }
@@ -169,9 +169,17 @@ public struct Device: Identifiable, Equatable, CustomStringConvertible {
169
169
  public struct ConnectOptions {
170
170
  public let saveAsDefault: Bool
171
171
  public let cancelExistingConnectionAttempt: Bool
172
+ /// Mentra Live on iOS only. Whether CoreBluetooth should require ANCS
173
+ /// authorization while establishing the connection.
174
+ public let requiresAncs: Bool
172
175
 
173
- public init(saveAsDefault: Bool = true, cancelExistingConnectionAttempt: Bool = true) {
176
+ public init(
177
+ saveAsDefault: Bool = true,
178
+ cancelExistingConnectionAttempt: Bool = true,
179
+ requiresAncs: Bool = true
180
+ ) {
174
181
  self.saveAsDefault = saveAsDefault
175
182
  self.cancelExistingConnectionAttempt = cancelExistingConnectionAttempt
183
+ self.requiresAncs = requiresAncs
176
184
  }
177
185
  }
@@ -0,0 +1,30 @@
1
+ import CoreBluetooth
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class MentraLiveConnectionOptionsTests: XCTestCase {
6
+ func testConnectOptionsRequireAncsByDefault() {
7
+ XCTAssertTrue(ConnectOptions().requiresAncs)
8
+ }
9
+
10
+ func testConnectOptionsCanDisableAncsRequirement() {
11
+ XCTAssertFalse(ConnectOptions(requiresAncs: false).requiresAncs)
12
+ }
13
+
14
+ #if os(macOS)
15
+ func testCoreBluetoothOptionsOmitAncsOnMacOS() {
16
+ XCTAssertNil(MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: true))
17
+ }
18
+ #else
19
+ func testCoreBluetoothOptionsRequireAncsWhenEnabled() {
20
+ let options = MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: true)
21
+
22
+ XCTAssertEqual(options?.count, 1)
23
+ XCTAssertEqual(options?[CBConnectPeripheralOptionRequiresANCS] as? Bool, true)
24
+ }
25
+ #endif
26
+
27
+ func testCoreBluetoothOptionsAreOmittedWhenAncsIsDisabled() {
28
+ XCTAssertNil(MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: false))
29
+ }
30
+ }
@@ -0,0 +1,32 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class VersionInfoBridgeTests: XCTestCase {
5
+ func testWireMetadataSurvivesBridgeNormalization() {
6
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
7
+ let delivered = expectation(description: "Both version chunks")
8
+ delivered.expectedFulfillmentCount = 2
9
+ var outcomes: [VersionInfoAccumulatorOutcome] = []
10
+ let sink = Bridge.addEventSink { type, body in
11
+ guard type == "version_info" else { return }
12
+ outcomes.append(accumulator.accept(body))
13
+ delivered.fulfill()
14
+ }
15
+ defer { Bridge.removeEventSink(sink) }
16
+ Bridge.sendVersionInfo([
17
+ "request_id": "request-1", "sid": "asg-1", "chunkCount": 2,
18
+ "chunkIndex": 1, "final": false, "build_number": "42",
19
+ ], responseChunk: "version_info_1")
20
+ Bridge.sendVersionInfo([
21
+ "request_id": "request-1", "sid": "asg-1", "chunkCount": 2,
22
+ "chunkIndex": 2, "final": true, "bes_fw_version": "new",
23
+ ], responseChunk: "version_info_3")
24
+ wait(for: [delivered], timeout: 2)
25
+ guard case .waiting = outcomes[0], case let .complete(result) = outcomes[1] else {
26
+ return XCTFail("Expected an explicitly complete combined response")
27
+ }
28
+ XCTAssertEqual(result.buildNumber, "42")
29
+ XCTAssertEqual(result.besFirmwareVersion, "new")
30
+ XCTAssertFalse(result.dictionary.keys.contains { $0.hasPrefix("_response") })
31
+ }
32
+ }
@@ -0,0 +1,181 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class VersionInfoResponseAccumulatorTests: XCTestCase {
5
+ func testMergesCurrentChunksAndCompletesOnFirmwareChunk() {
6
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
7
+
8
+ guard case .waiting = accumulator.accept(
9
+ chunk("version_info_1", "request-1", ["buildNumber": "42"])
10
+ )
11
+ else {
12
+ return XCTFail("Expected first chunk to wait")
13
+ }
14
+
15
+ let outcome = accumulator.accept(
16
+ chunk(
17
+ "version_info_3",
18
+ "request-1",
19
+ [
20
+ "buildNumber": "",
21
+ "besFirmwareVersion": "26.8.27.0",
22
+ "mtkFirmwareVersion": "MentraLive_20260709",
23
+ ]
24
+ )
25
+ )
26
+ guard case let .complete(result) = outcome else {
27
+ return XCTFail("Expected final chunk to complete")
28
+ }
29
+
30
+ XCTAssertEqual(result.buildNumber, "42")
31
+ XCTAssertEqual(result.besFirmwareVersion, "26.8.27.0")
32
+ XCTAssertEqual(result.mtkFirmwareVersion, "MentraLive_20260709")
33
+ }
34
+
35
+ func testIgnoresMismatchedAndTrailingStaleChunks() {
36
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
37
+
38
+ guard case .ignored = accumulator.accept(chunk("version_info_1", "other", ["buildNumber": "old"]))
39
+ else {
40
+ return XCTFail("Expected mismatched response to be ignored")
41
+ }
42
+ guard case .ignored = accumulator.accept(chunk("version_info_3", nil, ["besFirmwareVersion": "stale"]))
43
+ else {
44
+ return XCTFail("Expected stale trailing chunk to be ignored")
45
+ }
46
+ }
47
+
48
+ func testDoesNotMixCorrelatedAndUncorrelatedSequences() {
49
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
50
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"]))
51
+
52
+ guard case .ignored = accumulator.accept(
53
+ chunk("version_info_3", nil, ["besFirmwareVersion": "stale"])
54
+ ) else {
55
+ return XCTFail("Expected uncorrelated trailing chunk to be ignored")
56
+ }
57
+
58
+ let outcome = accumulator.accept(
59
+ chunk("version_info_3", "request-1", ["besFirmwareVersion": "current"])
60
+ )
61
+ guard case let .complete(result) = outcome else {
62
+ return XCTFail("Expected correlated final chunk to complete")
63
+ }
64
+ XCTAssertEqual(result.besFirmwareVersion, "current")
65
+ }
66
+
67
+ func testStaleUncorrelatedResponsesCannotReplaceCorrelatedSequence() {
68
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
69
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"]))
70
+
71
+ guard case .ignored = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "stale"]))
72
+ else {
73
+ return XCTFail("Expected stale first chunk to be ignored")
74
+ }
75
+ guard case .ignored = accumulator.accept(chunk("version_info", nil, ["buildNumber": "legacy"]))
76
+ else {
77
+ return XCTFail("Expected stale legacy response to be ignored")
78
+ }
79
+
80
+ let outcome = accumulator.accept(
81
+ chunk("version_info_3", "request-1", ["besFirmwareVersion": "current"])
82
+ )
83
+ guard case let .complete(result) = outcome else {
84
+ return XCTFail("Expected correlated final chunk to complete")
85
+ }
86
+ XCTAssertEqual(result.buildNumber, "42")
87
+ XCTAssertEqual(result.besFirmwareVersion, "current")
88
+ }
89
+
90
+ func testRepeatedFirstChunkResetsRatherThanMixingResponses() {
91
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
92
+ _ = accumulator.accept(chunk("version_info_1", nil, ["appVersion": "old"]))
93
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "43"]))
94
+
95
+ guard case let .complete(result) = accumulator.accept(
96
+ chunk("version_info_3", nil, ["besFirmwareVersion": "new"])
97
+ ) else { return XCTFail("Legacy final chunk must complete immediately") }
98
+ XCTAssertEqual(result.appVersion, "")
99
+ XCTAssertEqual(result.buildNumber, "43")
100
+ XCTAssertEqual(result.besFirmwareVersion, "new")
101
+ }
102
+
103
+ func testLegacySingleMessageCompletesImmediately() {
104
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
105
+
106
+ let outcome = accumulator.accept(chunk("version_info", nil, ["buildNumber": "7"]))
107
+ guard case let .complete(result) = outcome else {
108
+ return XCTFail("Expected legacy response to complete")
109
+ }
110
+ XCTAssertEqual(result.buildNumber, "7")
111
+ }
112
+
113
+ func testLegacyFirstChunkDoesNotComplete() {
114
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
115
+ guard case .waiting = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "8"])) else {
116
+ return XCTFail("Partial response must wait for final chunk or deadline")
117
+ }
118
+ }
119
+
120
+ func testModernFinalChunkWaitsForMissingFirstChunk() {
121
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
122
+ guard case .waiting = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])) else {
123
+ return XCTFail("Missing first chunk")
124
+ }
125
+ guard case let .complete(result) = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "42"])) else {
126
+ return XCTFail("Both chunks arrived")
127
+ }
128
+ XCTAssertEqual(result.buildNumber, "42")
129
+ XCTAssertEqual(result.besFirmwareVersion, "new")
130
+ }
131
+
132
+ func testDuplicateDoesNotCountAsMissingChunk() {
133
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
134
+ let first = chunk("version_info_1", "request-1", ["buildNumber": "42"])
135
+ _ = accumulator.accept(first)
136
+ guard case .waiting = accumulator.accept(first) else { return XCTFail("Duplicate is not completion") }
137
+ }
138
+
139
+ func testRejectsMalformedModernMetadataAndMixedProcess() {
140
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
141
+ let first = chunk("version_info_1", "request-1", ["buildNumber": "42"])
142
+ for (key, value) in [
143
+ VersionInfoResponseAccumulator.responseCountKey: 2.5,
144
+ VersionInfoResponseAccumulator.responseIndexKey: true,
145
+ VersionInfoResponseAccumulator.responseFinalKey: true,
146
+ VersionInfoResponseAccumulator.responseRequestIdKey: "",
147
+ ] as [String: Any] {
148
+ var invalid = first
149
+ invalid[key] = value
150
+ guard case .ignored = accumulator.accept(invalid) else { return XCTFail("Invalid " + key) }
151
+ }
152
+ _ = accumulator.accept(first)
153
+ let last = chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])
154
+ var restarted = last
155
+ restarted[VersionInfoResponseAccumulator.responseSidKey] = "process-2"
156
+ guard case .ignored = accumulator.accept(restarted) else { return XCTFail("Mixed process") }
157
+ guard case .complete = accumulator.accept(last) else { return XCTFail("Matching final chunk") }
158
+ }
159
+
160
+ func testCorrelatedResponseWithoutCompletenessMetadataCannotComplete() {
161
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
162
+ let event: [String: Any] = [
163
+ VersionInfoResponseAccumulator.responseChunkKey: "version_info_3",
164
+ VersionInfoResponseAccumulator.responseRequestIdKey: "request-1",
165
+ ]
166
+ guard case .ignored = accumulator.accept(event) else { return XCTFail("Incomplete modern metadata") }
167
+ }
168
+
169
+ private func chunk(_ type: String, _ requestId: String?, _ values: [String: Any]) -> [String: Any] {
170
+ var event = values
171
+ event[VersionInfoResponseAccumulator.responseChunkKey] = type
172
+ if let requestId {
173
+ event[VersionInfoResponseAccumulator.responseRequestIdKey] = requestId
174
+ event[VersionInfoResponseAccumulator.responseIndexKey] = type == "version_info_1" ? 1 : 2
175
+ event[VersionInfoResponseAccumulator.responseCountKey] = 2
176
+ event[VersionInfoResponseAccumulator.responseFinalKey] = type == "version_info_3"
177
+ event[VersionInfoResponseAccumulator.responseSidKey] = "process-1"
178
+ }
179
+ return event
180
+ }
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.0-dev.193",
3
+ "version": "3.2.0-dev.200",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -1485,6 +1485,13 @@ export interface Device {
1485
1485
  export interface ConnectOptions {
1486
1486
  saveAsDefault?: boolean
1487
1487
  cancelExistingConnectionAttempt?: boolean
1488
+ /**
1489
+ * iOS Mentra Live only. When true, CoreBluetooth requires ANCS authorization
1490
+ * as part of the connection. Set false when the app does not use notification
1491
+ * relay and must avoid the system ANCS authorization flow. Defaults to true.
1492
+ * Android accepts this option as a no-op.
1493
+ */
1494
+ requiresAncs?: boolean
1488
1495
  }
1489
1496
 
1490
1497
  export type ScanResultsCallback = (devices: Device[]) => void
@@ -295,6 +295,7 @@ installNativeLogConsole(NativeBluetoothSdkModule, Platform.OS)
295
295
  const DEFAULT_CONNECT_OPTIONS: Required<ConnectOptions> = {
296
296
  saveAsDefault: true,
297
297
  cancelExistingConnectionAttempt: true,
298
+ requiresAncs: true,
298
299
  }
299
300
 
300
301
  const DEFAULT_SCAN_TIMEOUT_MS = 15_000
@@ -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.2.0",
15
- "releaseIdentity": "3.2.0-dev.193",
16
- "releaseSetId": "mentra-3.2.0-dev.193",
17
- "sourceCommit": "ca3b4bd4109d80253a3369cbf1ca8f9e86236fba",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.193.json",
19
- "otaManifestSha256": "b5201e1a9d926df220adfbf26cba934948d3f09ed81453c3600770a7618dd997"
15
+ "releaseIdentity": "3.2.0-dev.200",
16
+ "releaseSetId": "mentra-3.2.0-dev.200",
17
+ "sourceCommit": "1cdd911f596eba7b2b2f0e9ba5655c7ff16b5362",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.200.json",
19
+ "otaManifestSha256": "f0b5cca2fa4d1d43acf5933fb4de629819d36a145a162c763f4faa27a7c8ff79"
20
20
  })