@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.
- package/README.md +30 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
- package/build/BluetoothSdk.types.d.ts +7 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/react/useGlassesConnection.d.ts +5 -0
- package/build/react/useGlassesConnection.d.ts.map +1 -1
- package/build/react/useGlassesConnection.js +2 -2
- package/build/react/useGlassesConnection.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +5 -0
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +1 -0
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +2 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +15 -1
- package/ios/Source/DeviceManager.swift +11 -5
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +42 -9
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
- package/ios/Source/sgcs/MentraLive.swift +29 -5
- package/ios/Source/status/DeviceStatus.swift +2 -3
- package/ios/Source/types/DeviceModels.swift +9 -1
- package/ios/Tests/MentraLiveConnectionOptionsTests.swift +30 -0
- package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +7 -0
- package/src/_private/BluetoothSdkModule.ts +1 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/react/useGlassesConnection.ts +7 -2
- 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.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
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:
|
|
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
|
-
|
|
1242
|
-
|
|
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 ===
|
|
1263
|
+
if pendingVersionInfo === request {
|
|
1246
1264
|
pendingVersionInfo = nil
|
|
1247
1265
|
}
|
|
1248
1266
|
return status
|
|
1249
1267
|
} catch {
|
|
1250
|
-
if pendingVersionInfo ===
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
2452
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
424
|
-
//
|
|
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(
|
|
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
|
@@ -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.
|
|
16
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
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
|
})
|