@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -0,0 +1,172 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class G2ConnectionSessionTests: XCTestCase {
5
+ func testEitherArmAloneCannotStartAuthenticationOrBecomeReady() {
6
+ for side in G2ConnectionSession.Side.allCases {
7
+ var session = G2ConnectionSession()
8
+ session.initialize(side)
9
+ XCTAssertNil(session.beginAuthentication())
10
+ session.authenticated(side, success: true)
11
+ session.completeSetup(generation: session.generation)
12
+ XCTAssertFalse(session.isReady)
13
+ }
14
+ }
15
+
16
+ func testReadinessWaitsForBothAuthenticationResponsesAndCompletedSetup() throws {
17
+ for completeSetupFirst in [false, true] {
18
+ var session = G2ConnectionSession()
19
+ session.initialize(.right)
20
+ session.initialize(.left)
21
+ let generation = try XCTUnwrap(session.beginAuthentication())
22
+ XCTAssertNil(session.beginAuthentication(), "Duplicate service callbacks must not restart setup")
23
+ if completeSetupFirst { session.completeSetup(generation: generation) }
24
+ session.authenticated(.left, success: true)
25
+ session.authenticated(.right, success: false)
26
+ XCTAssertFalse(session.isReady)
27
+ session.authenticated(.right, success: true)
28
+ XCTAssertEqual(session.isReady, completeSetupFirst)
29
+ session.completeSetup(generation: generation)
30
+ XCTAssertTrue(session.isReady)
31
+ }
32
+ }
33
+
34
+ func testDisconnectRetiresAuthenticationAndSuspendedSetup() throws {
35
+ var session = G2ConnectionSession()
36
+ session.initialize(.left)
37
+ session.initialize(.right)
38
+ let oldGeneration = try XCTUnwrap(session.beginAuthentication())
39
+ session.authenticated(.left, success: true)
40
+ session.reset()
41
+ session.authenticated(.right, success: true)
42
+ session.completeSetup(generation: oldGeneration)
43
+ XCTAssertFalse(session.isReady)
44
+
45
+ session.initialize(.left)
46
+ session.initialize(.right)
47
+ let newGeneration = try XCTUnwrap(session.beginAuthentication())
48
+ session.authenticated(.left, success: true)
49
+ session.authenticated(.right, success: true)
50
+ session.completeSetup(generation: oldGeneration)
51
+ XCTAssertFalse(session.isReady, "Old setup cannot complete the replacement attempt")
52
+ session.completeSetup(generation: newGeneration)
53
+ XCTAssertTrue(session.isReady)
54
+ }
55
+
56
+ func testAttemptDeadlineCoversBothPartialPairsAndIncompleteAuthentication() throws {
57
+ for sides: [G2ConnectionSession.Side] in [[], [.left], [.right], [.left, .right]] {
58
+ var session = G2ConnectionSession()
59
+ for side in sides {
60
+ session.initialize(side)
61
+ }
62
+ _ = session.beginAuthentication()
63
+ let attempt = session.generation
64
+ XCTAssertTrue(session.needsRecovery(attempt: attempt))
65
+ session.reset()
66
+ XCTAssertFalse(session.needsRecovery(attempt: attempt), "A retired deadline cannot reset a new attempt")
67
+ }
68
+ var ready = G2ConnectionSession()
69
+ ready.initialize(.left)
70
+ ready.initialize(.right)
71
+ let attempt = try XCTUnwrap(ready.beginAuthentication())
72
+ ready.authenticated(.left, success: true)
73
+ ready.authenticated(.right, success: true)
74
+ ready.completeSetup(generation: attempt)
75
+ XCTAssertFalse(ready.needsRecovery(attempt: attempt))
76
+ }
77
+
78
+ func testCachedArmsCanReconnectIndependentlyWithoutAdvertisements() {
79
+ let left = UUID(), right = UUID()
80
+ XCTAssertEqual(target(left, left: left), .left)
81
+ XCTAssertEqual(target(right, right: right), .right)
82
+ XCTAssertNil(target(UUID(), left: left, right: right))
83
+ XCTAssertNil(target(right, left: right, right: right), "Ambiguous cache must not bind one arm twice")
84
+ }
85
+
86
+ func testOnlySelectedSerialCanFillTheMissingArm() {
87
+ let id = UUID()
88
+ XCTAssertEqual(target(id, name: "Even G2_32_L_123456", serial: "S211SELECTED"), .left)
89
+ XCTAssertNil(target(id, name: "Even G2_32_L_123456", serial: "S211OTHER"))
90
+ XCTAssertNil(target(id, name: "Even G2_32_R_123456"))
91
+ XCTAssertNil(target(id, right: id, searchID: "NOT_SET"))
92
+ XCTAssertNil(target(id, right: id, searchID: ""))
93
+ }
94
+
95
+ private func target(
96
+ _ id: UUID, name: String? = nil, serial: String? = nil,
97
+ left: UUID? = nil, right: UUID? = nil, searchID: String = "S211SELECTED"
98
+ ) -> G2ConnectionSession.Side? {
99
+ G2ConnectionTarget.side(identifier: id, name: name, advertisedSerial: serial,
100
+ searchID: searchID, leftUUID: left, rightUUID: right)
101
+ }
102
+ }
103
+
104
+ @MainActor
105
+ final class G2PrematureReadinessTests: XCTestCase {
106
+ func testSystemExitAndTouchBeforePairInitializationDoNotPublishReady() {
107
+ let store = DeviceStore.shared.store
108
+ let connected = store.get("glasses", "connected")
109
+ let booted = store.get("glasses", "fullyBooted")
110
+ defer {
111
+ if let connected { store.set("glasses", "connected", connected) }
112
+ if let booted { store.set("glasses", "fullyBooted", booted) }
113
+ }
114
+ store.set("glasses", "connected", false)
115
+ store.set("glasses", "fullyBooted", false)
116
+ let g2 = G2()
117
+ // SendDeviceEvent.SysEvent { eventType: systemExit }, as observed on the right arm.
118
+ g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x07]))
119
+ XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
120
+ XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
121
+ g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x00]))
122
+ XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
123
+ XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
124
+ }
125
+ }
126
+
127
+ final class G2PartialConnectionTests: XCTestCase {
128
+ func testEitherPartialLinkStaysQuietForThreeSeconds() {
129
+ for leftConnected in [false, true] {
130
+ var progress = G2PartialConnection()
131
+ progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 10)
132
+ XCTAssertNil(progress.visibleMissingSide(now: 10))
133
+ XCTAssertNil(progress.visibleMissingSide(now: 12.999))
134
+ // Duplicate observations must not restart the grace period.
135
+ progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 12)
136
+ XCTAssertEqual(progress.visibleMissingSide(now: 13), leftConnected ? .right : .left)
137
+ XCTAssertEqual(progress.timeoutError, leftConnected ? "errors:g2RightArmUnavailable" : "errors:g2LeftArmUnavailable")
138
+ }
139
+ }
140
+
141
+ func testNormalSequentialPairingAndDisconnectedStateNeverShowMissingArm() {
142
+ var progress = G2PartialConnection()
143
+ progress.update(leftConnected: false, rightConnected: true, now: 10)
144
+ progress.update(leftConnected: true, rightConnected: true, now: 12)
145
+ XCTAssertNil(progress.visibleMissingSide(now: 15))
146
+ progress.update(leftConnected: false, rightConnected: false, now: 16)
147
+ XCTAssertNil(progress.visibleMissingSide(now: 20))
148
+ XCTAssertEqual(progress.timeoutError, "errors:g2ConnectionTimedOut")
149
+ }
150
+
151
+ func testPeerArrivalClearsVisibleNoticeAndNewPartialLinkGetsOwnGracePeriod() {
152
+ var progress = G2PartialConnection()
153
+ progress.update(leftConnected: true, rightConnected: false, now: 10)
154
+ XCTAssertEqual(progress.visibleMissingSide(now: 13), .right)
155
+ progress.update(leftConnected: true, rightConnected: true, now: 14)
156
+ XCTAssertNil(progress.visibleMissingSide(now: 14))
157
+ progress.update(leftConnected: false, rightConnected: true, now: 15)
158
+ XCTAssertNil(progress.visibleMissingSide(now: 17))
159
+ XCTAssertEqual(progress.visibleMissingSide(now: 18), .left)
160
+ progress = G2PartialConnection()
161
+ XCTAssertNil(progress.visibleMissingSide(now: 100))
162
+ }
163
+
164
+ func testStatusSnapshotAndUpdateCarryNoticeAndDisconnectClearsIt() throws {
165
+ let status = GlassesStatus(values: ["g2MissingArm": "left"])
166
+ XCTAssertEqual(status.dictionary["g2MissingArm"] as? String, "left")
167
+ XCTAssertEqual(GlassesStatusUpdate(values: ["g2MissingArm": "right"]).dictionary["g2MissingArm"] as? String, "right")
168
+ let disconnected = status.disconnected().dictionary
169
+ XCTAssertTrue(disconnected["g2MissingArm"] is NSNull)
170
+ XCTAssertNoThrow(try JSONSerialization.data(withJSONObject: disconnected))
171
+ }
172
+ }
@@ -0,0 +1,10 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class G2TextTests: XCTestCase {
5
+ func testBlankRowsAreEncodedOnlyAtTheDeviceBoundary() {
6
+ XCTAssertEqual(G2Text.containerContent("hello\n\nworld\n"), "hello\n\u{200B}\nworld\n\u{200B}")
7
+ XCTAssertEqual(G2Text.containerContent(""), " ")
8
+ XCTAssertEqual(G2Text.containerContent("hello\nworld"), "hello\nworld")
9
+ }
10
+ }
@@ -0,0 +1,111 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ /// Keep native admission, serialization and fragment packing; replace only the radio writes.
5
+ @MainActor
6
+ private final class IncidentLogTransport: MentraLive {
7
+ var ready = true
8
+ var writes: [(Data, BleWriteTrace?)] = []
9
+
10
+ override var incidentLogTransportReady: Bool {
11
+ ready
12
+ }
13
+
14
+ override func queueSend(_ data: Data, id _: String, trace: BleWriteTrace?) {
15
+ writes.append((data, trace))
16
+ }
17
+ }
18
+
19
+ @MainActor
20
+ final class IncidentLogDispatchTests: XCTestCase {
21
+ private let incident = "rep_01M4HBVT88DFSN8TYNZ1WTV4PB"
22
+ private let apiBase = "https://core.dev.us-west-2.mentraglass.com"
23
+
24
+ func testMissingNativeLinkRejectsDespiteCachedConnectedStatus() {
25
+ let live = MentraLive()
26
+ let store = DeviceStore.shared.store
27
+ let saved = store.getCategory("glasses")["connected"]
28
+ store.set("glasses", "connected", true)
29
+ defer {
30
+ if let saved { store.set("glasses", "connected", saved) }
31
+ else { store.remove("glasses", "connected") }
32
+ }
33
+ XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
34
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
35
+ }
36
+ }
37
+
38
+ func testNoDriverRejectsThroughDeviceManager() {
39
+ let manager = DeviceManager()
40
+ manager.sgc = nil
41
+ XCTAssertThrowsError(try manager.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
42
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
43
+ }
44
+ }
45
+
46
+ func testMissingCurrentTokenDoesNotQueueAnUpload() throws {
47
+ try withToken("") {
48
+ let live = IncidentLogTransport()
49
+ XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
50
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "core_token_unavailable")
51
+ }
52
+ XCTAssertTrue(live.writes.isEmpty)
53
+ }
54
+ }
55
+
56
+ func testCurrentTokenPrecedesUploadAndNeverAppearsInLogs() throws {
57
+ let token = "synthetic-\(UUID().uuidString)"
58
+ try withToken(token) {
59
+ let live = IncidentLogTransport()
60
+ let manager = DeviceManager()
61
+ manager.sgc = live
62
+ var logs: [String] = []
63
+ let sink = Bridge.addEventSink { event, body in
64
+ if event == "log", let message = body["message"] as? String { logs.append(message) }
65
+ }
66
+ defer { Bridge.removeEventSink(sink) }
67
+
68
+ try manager.sendIncidentId(incident, apiBaseUrl: apiBase)
69
+
70
+ XCTAssertEqual(live.writes.compactMap { $0.1?.commandType }, ["auth_token", "upload_incident_logs"])
71
+ XCTAssertTrue(live.writes.allSatisfy { $0.1?.wakeup == true })
72
+ let wire = live.writes.map { String(decoding: $0.0, as: UTF8.self) }.joined()
73
+ XCTAssertTrue(wire.contains(token), "The current token must reach the glasses")
74
+ XCTAssertTrue(wire.contains(incident))
75
+ XCTAssertFalse(logs.contains { $0.contains(token) }, "Tokens must stay out of native and incident logs")
76
+ }
77
+ }
78
+
79
+ func testAllTokenFragmentsPrecedeUpload() throws {
80
+ try withToken(String(repeating: "synthetic", count: 120)) {
81
+ let live = IncidentLogTransport()
82
+ try live.sendIncidentId(incident, apiBaseUrl: apiBase)
83
+ let types = live.writes.compactMap { $0.1?.commandType }
84
+ XCTAssertGreaterThan(types.filter { $0 == "auth_token" }.count, 1)
85
+ XCTAssertEqual(types.last, "upload_incident_logs")
86
+ XCTAssertTrue(types.dropLast().allSatisfy { $0 == "auth_token" })
87
+ }
88
+ }
89
+
90
+ func testCommandQueueInsertionAndSessionClearAreSynchronous() {
91
+ let queue = MentraLive.CommandQueue()
92
+ let first = MentraLive.PendingMessage(data: Data([1]), id: "auth", retries: 0)
93
+ let second = MentraLive.PendingMessage(data: Data([2]), id: "upload", retries: 0)
94
+ XCTAssertEqual(queue.enqueue(first), 1)
95
+ XCTAssertEqual(queue.enqueue(second), 2)
96
+ XCTAssertEqual(queue.dequeue()?.id, "auth")
97
+ queue.removeAll()
98
+ XCTAssertNil(queue.dequeue())
99
+ }
100
+
101
+ private func withToken<T>(_ value: String, body: () throws -> T) rethrows -> T {
102
+ let store = DeviceStore.shared.store
103
+ let saved = store.getCategory("bluetooth")["core_token"]
104
+ store.set("bluetooth", "core_token", value)
105
+ defer {
106
+ if let saved { store.set("bluetooth", "core_token", saved) }
107
+ else { store.remove("bluetooth", "core_token") }
108
+ }
109
+ return try body()
110
+ }
111
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class MentraLiveFileTransferSessionTests: XCTestCase {
6
+ func testDynamicPayloadUsesTrueSizeEvenWhenDivisibleBy400() {
7
+ for pack in [244, 474, 800] {
8
+ var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: 80000, flags: 2)
9
+ session.recalculateTotalPackets(actualPackSize: pack)
10
+ XCTAssertEqual(session.totalPackets, (80000 + pack - 1) / pack)
11
+ for index in 0 ..< session.totalPackets {
12
+ XCTAssertTrue(session.addPacket(index, data: Data(repeating: 7, count: min(pack, 80000 - index * pack))))
13
+ }
14
+ XCTAssertTrue(session.isComplete)
15
+ XCTAssertEqual(session.assembleFile()?.count, 80000)
16
+ }
17
+ }
18
+
19
+ func testLegacyInflatedSizeAndLargeUnflaggedPayloads() {
20
+ // Old senders inflate the header length for small packs, including
21
+ // the short final pack. Flag 1 only enables batched ACKs.
22
+ for (pack, headerSize, actualSize, packets) in [(221, 80000, 43982, 200), (400, 80000, 79777, 200), (800, 80000, 80000, 100)] {
23
+ var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: headerSize, flags: 1)
24
+ session.recalculateTotalPackets(actualPackSize: pack)
25
+ XCTAssertEqual(session.totalPackets, packets)
26
+ let source = Data((0 ..< actualSize).map { UInt8($0 % 251) })
27
+ for index in (0 ..< packets).reversed() {
28
+ let start = index * pack
29
+ XCTAssertTrue(session.addPacket(index, data: source.subdata(in: start ..< min(start + pack, actualSize))))
30
+ }
31
+ XCTAssertTrue(session.isComplete)
32
+ XCTAssertEqual(session.assembleFile(), source)
33
+ }
34
+ }
35
+
36
+ func testMalformedOrDuplicatePacketsCannotCompleteFile() {
37
+ var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: 1000, flags: 2)
38
+ session.recalculateTotalPackets(actualPackSize: 800)
39
+ XCTAssertFalse(session.addPacket(0, data: Data(count: 799)))
40
+ XCTAssertFalse(session.addPacket(2, data: Data(count: 200)))
41
+ XCTAssertTrue(session.addPacket(1, data: Data(count: 200)))
42
+ XCTAssertFalse(session.isComplete)
43
+ XCTAssertFalse(session.addPacket(1, data: Data(count: 200)))
44
+ XCTAssertTrue(session.addPacket(0, data: Data(count: 800)))
45
+ XCTAssertEqual(session.assembleFile()?.count, 1000)
46
+ }
47
+ }
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ @MainActor
5
+ private final class SessionReadinessTransport: MentraLive {
6
+ var commands: [[String: Any]] = []
7
+
8
+ override func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool, requireAck: Bool) {
9
+ commands.append(jsonOriginal)
10
+ }
11
+ }
12
+
13
+ @MainActor
14
+ final class MentraLiveSessionReadinessTests: XCTestCase {
15
+ func testChangedSessionPublishesPendingReadinessBeforeItsBuildAndRecoversAtGlassesReady() throws {
16
+ let store = DeviceStore.shared.store
17
+ let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
18
+ let previous = DeviceManager.shared.sgc
19
+ let transport = SessionReadinessTransport()
20
+ let sdk = MentraBluetoothSDK()
21
+ DeviceManager.shared.sgc = transport
22
+ var observations: [(key: String, ready: Bool, connected: Bool)] = []
23
+ let listener = store.addListener { category, changed in
24
+ guard category == "glasses" else { return }
25
+ for key in changed.keys where key == "fullyBooted" || key == "buildNumber" {
26
+ observations.append((key, sdk.glassesStatus.fullyBooted, sdk.glassesStatus.connected))
27
+ }
28
+ }
29
+ var sessionEvents: [Bool] = []
30
+ let sink = Bridge.addEventSink { event, body in
31
+ if event == "glasses_session_changed" {
32
+ sessionEvents.append(sdk.glassesStatus.fullyBooted)
33
+ }
34
+ }
35
+ defer {
36
+ store.removeListener(listener)
37
+ Bridge.removeEventSink(sink)
38
+ sdk.invalidate()
39
+ transport.cleanup()
40
+ DeviceManager.shared.sgc = previous
41
+ for (category, values) in saved {
42
+ for key in store.getCategory(category).keys where values[key] == nil {
43
+ store.remove(category, key)
44
+ }
45
+ for (key, value) in values { store.set(category, key, value) }
46
+ }
47
+ }
48
+
49
+ func receive(_ value: [String: Any]) throws {
50
+ try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
51
+ }
52
+ try receive(["type": "glasses_ready", "sid": "original"])
53
+ observations.removeAll()
54
+ transport.commands.removeAll()
55
+
56
+ // The physical BLE link remains live across APK replacement. Its first
57
+ // version chunk can prove the target build before remote wire readiness.
58
+ try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
59
+ XCTAssertEqual(observations.map(\.key), ["fullyBooted", "buildNumber"])
60
+ XCTAssertTrue(observations.allSatisfy { !$0.ready && $0.connected })
61
+ XCTAssertEqual(sessionEvents, [false])
62
+ XCTAssertEqual(transport.commands.compactMap { $0["type"] as? String }, ["phone_ready"])
63
+
64
+ // Another chunk from the same pending process must not restart readiness.
65
+ try receive(["type": "version_info_3", "sid": "replacement"])
66
+ XCTAssertEqual(sessionEvents, [false])
67
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
68
+
69
+ try receive(["type": "glasses_ready", "sid": "replacement"])
70
+ XCTAssertTrue(sdk.glassesStatus.connected)
71
+ XCTAssertTrue(sdk.glassesStatus.fullyBooted)
72
+ XCTAssertEqual(observations.last?.key, "fullyBooted")
73
+ XCTAssertEqual(observations.last?.ready, true)
74
+ XCTAssertEqual(transport.commands.filter { $0["type"] as? String == "request_version" }.count, 1)
75
+
76
+ observations.removeAll()
77
+ try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
78
+ XCTAssertTrue(sdk.glassesStatus.fullyBooted)
79
+ XCTAssertFalse(observations.contains { $0.key == "fullyBooted" })
80
+ XCTAssertEqual(sessionEvents, [false])
81
+
82
+ // A physical teardown before the next logical restart becomes ready must
83
+ // still run disconnect cleanup, even though readiness is already false.
84
+ try receive(["type": "version_info_1", "sid": "third", "build_number": "303000267"])
85
+ store.set("glasses", "headUp", true)
86
+ store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
87
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
88
+ XCTAssertTrue(sdk.glassesStatus.connected)
89
+ transport.cleanup()
90
+ XCTAssertFalse(sdk.glassesStatus.connected)
91
+ XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
92
+ XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
93
+ BluetoothSdkDefaults.voiceActivityDetectionEnabled)
94
+ }
95
+
96
+ func testPairingYieldDuringPendingReadinessCleansUpAndReclaimsAfterItsWindow() async throws {
97
+ let store = DeviceStore.shared.store
98
+ let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
99
+ let previous = DeviceManager.shared.sgc
100
+ let transport = SessionReadinessTransport()
101
+ let sdk = MentraBluetoothSDK()
102
+ DeviceManager.shared.sgc = transport
103
+ defer {
104
+ sdk.invalidate()
105
+ transport.cleanup()
106
+ DeviceManager.shared.sgc = previous
107
+ for (category, values) in saved {
108
+ for key in store.getCategory(category).keys where values[key] == nil {
109
+ store.remove(category, key)
110
+ }
111
+ for (key, value) in values { store.set(category, key, value) }
112
+ }
113
+ }
114
+
115
+ func receive(_ value: [String: Any]) throws {
116
+ try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
117
+ }
118
+ try receive(["type": "glasses_ready", "sid": "original"])
119
+ try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
120
+ XCTAssertTrue(sdk.glassesStatus.connected)
121
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
122
+ store.set("glasses", "headUp", true)
123
+ store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
124
+
125
+ try receive(["type": "entering_pairing_mode", "window_ms": 5000])
126
+ XCTAssertTrue(transport.isPairingYieldActive())
127
+ XCTAssertFalse(sdk.glassesStatus.connected)
128
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
129
+ XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
130
+ XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
131
+ BluetoothSdkDefaults.voiceActivityDetectionEnabled)
132
+
133
+ // Readiness queued on the relinquished GATT must not close the yield.
134
+ try receive(["type": "glasses_ready", "sid": "replacement"])
135
+ XCTAssertTrue(transport.isPairingYieldActive())
136
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
137
+
138
+ // Let the existing minimum yield expire, then accept a fresh readiness.
139
+ try await Task.sleep(nanoseconds: 5_100_000_000)
140
+ XCTAssertFalse(transport.isPairingYieldActive())
141
+ try receive(["type": "glasses_ready", "sid": "reclaimed"])
142
+ XCTAssertTrue(sdk.glassesStatus.connected)
143
+ XCTAssertTrue(sdk.glassesStatus.fullyBooted)
144
+ }
145
+
146
+ func testInitialVersionChunkDoesNotStartAnExtraReadinessExchange() throws {
147
+ let store = DeviceStore.shared.store
148
+ let saved = store.getCategory("glasses")
149
+ let transport = SessionReadinessTransport()
150
+ defer {
151
+ transport.cleanup()
152
+ for key in store.getCategory("glasses").keys where saved[key] == nil { store.remove("glasses", key) }
153
+ for (key, value) in saved { store.set("glasses", key, value) }
154
+ }
155
+ store.set("glasses", "fullyBooted", false)
156
+ try transport.processReceivedData(JSONSerialization.data(withJSONObject: [
157
+ "type": "version_info_1", "sid": "initial", "build_number": "303000267",
158
+ ]))
159
+ XCTAssertTrue(transport.commands.isEmpty)
160
+ XCTAssertEqual(store.get("glasses", "buildNumber") as? String, "303000267")
161
+ XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
162
+ }
163
+ }