@mentra/bluetooth-sdk 3.4.0-dev.993 → 3.4.0-dev.998

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 (68) hide show
  1. package/MIGRATION.md +61 -0
  2. package/README.md +47 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
  27. package/build/BluetoothSdk.types.d.ts +101 -0
  28. package/build/BluetoothSdk.types.d.ts.map +1 -1
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +1 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/generated/releaseMetadata.js +5 -5
  34. package/build/generated/releaseMetadata.js.map +1 -1
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +3 -0
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +8 -2
  40. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  41. package/ios/Source/Bridge.swift +11 -3
  42. package/ios/Source/DeviceManager.swift +80 -6
  43. package/ios/Source/DeviceStore.swift +27 -0
  44. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +10 -4
  46. package/ios/Source/errors/BluetoothSdkError.swift +5 -1
  47. package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
  48. package/ios/Source/sgcs/MentraLive.swift +1053 -166
  49. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
  50. package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
  51. package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
  52. package/ios/Source/sgcs/SGCManager.swift +7 -0
  53. package/ios/Source/sgcs/Simulated.swift +2 -0
  54. package/ios/Source/status/DeviceStatus.swift +35 -0
  55. package/ios/Source/types/DeviceModels.swift +8 -1
  56. package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
  57. package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
  58. package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
  59. package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
  60. package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
  61. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
  62. package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
  63. package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
  64. package/package.json +3 -2
  65. package/src/BluetoothSdk.types.ts +97 -0
  66. package/src/_private/BluetoothSdkModule.ts +1 -0
  67. package/src/generated/releaseMetadata.ts +5 -5
  68. package/src/index.ts +10 -0
@@ -0,0 +1,368 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothDevice
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class MentraLiveOwnerLossPolicyTest {
10
+ @Test
11
+ fun connectTimeRejectionIsAHintOnlyOutsideExplicitPairingOnSecureFirmware() {
12
+ assertTrue(gattRejection(5))
13
+ assertTrue(gattRejection(15))
14
+ assertFalse(gattRejection(5, manualPairing = true))
15
+ }
16
+
17
+ @Test
18
+ fun legacyFirmwareRejectionsAreNeverOwnerLoss() {
19
+ assertFalse(gattRejection(5, secureFirmware = false))
20
+ assertFalse(gattRejection(15, secureFirmware = false))
21
+ assertFalse(bondRemoved(msSinceAutoReconnectLink = 600L, secureFirmware = false))
22
+ }
23
+
24
+ @Test
25
+ fun rfDropsAndRemoteTerminationAreNotOwnerLoss() {
26
+ for (status in listOf(8, 19, 22, 62, 133)) {
27
+ assertFalse("status $status", gattRejection(status))
28
+ }
29
+ }
30
+
31
+ @Test
32
+ fun bondDroppedRightAfterAutomaticReconnectIsARejection() {
33
+ assertTrue(bondRemoved(msSinceAutoReconnectLink = 600L))
34
+ }
35
+
36
+ @Test
37
+ fun bondRemovalOutsideAReconnectIsNotARejection() {
38
+ assertFalse(bondRemoved(msSinceAutoReconnectLink = null))
39
+ assertFalse(bondRemoved(msSinceAutoReconnectLink = 60_000L))
40
+ }
41
+
42
+ @Test
43
+ fun appUnpairAndExplicitPairingNeverReportOwnerLoss() {
44
+ assertFalse(bondRemoved(msSinceAutoReconnectLink = 600L, killed = true))
45
+ assertFalse(bondRemoved(msSinceAutoReconnectLink = 600L, unpairInProgress = true))
46
+ assertFalse(bondRemoved(msSinceAutoReconnectLink = 600L, manualPairing = true))
47
+ }
48
+
49
+ @Test
50
+ fun aSingleRejectionDoesNotStandDown() {
51
+ val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
52
+ assertFalse(streak.record(0L))
53
+ }
54
+
55
+ @Test
56
+ fun threeQuickRejectionsDoNotStandDownUntilTheStreakSpansAboutAMinute() {
57
+ val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
58
+ assertFalse(streak.record(0L))
59
+ assertFalse(streak.record(3_000L))
60
+ // Third attempt, but a glasses reboot also rejects for a few seconds.
61
+ assertFalse(streak.record(8_000L))
62
+ assertFalse(streak.record(24_000L))
63
+ assertTrue(streak.record(MentraLiveOwnerLossPolicy.MIN_REJECTION_SPAN_MS))
64
+ }
65
+
66
+ @Test
67
+ fun aSuccessfulSessionEndsTheStreak() {
68
+ val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
69
+ streak.record(0L)
70
+ streak.record(10_000L)
71
+ streak.reset()
72
+ assertFalse(streak.record(50_000L))
73
+ assertFalse(streak.record(60_000L))
74
+ }
75
+
76
+ @Test
77
+ fun rejectionsFarApartAreNotOneStreak() {
78
+ val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
79
+ streak.record(0L)
80
+ streak.record(10_000L)
81
+ streak.record(20_000L)
82
+ // A long quiet gap means the earlier rejections were something else.
83
+ val later = 20_000L + MentraLiveOwnerLossPolicy.REJECTION_STREAK_GAP_MS + 1L
84
+ assertFalse(streak.record(later))
85
+ assertFalse(streak.record(later + 10_000L))
86
+ assertFalse(streak.record(later + 20_000L))
87
+ }
88
+
89
+ @Test
90
+ fun oneAttemptReportingTwiceCountsOnce() {
91
+ val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
92
+ val span = MentraLiveOwnerLossPolicy.MIN_REJECTION_SPAN_MS
93
+ // Bond removed, then the GATT disconnect with status 5, for each of two attempts.
94
+ // Counted per signal this would be four rejections over a long enough span.
95
+ assertFalse(streak.record(0L))
96
+ assertFalse(streak.record(400L))
97
+ assertFalse(streak.record(span))
98
+ assertFalse(streak.record(span + 400L))
99
+ // A genuine third attempt completes the streak.
100
+ assertTrue(streak.record(span + 10_000L))
101
+ }
102
+
103
+ @Test
104
+ fun aFailedNewBondIsNotABondRemoval() {
105
+ assertFalse(
106
+ MentraLiveOwnerLossPolicy.isOwnerRejectionOnBondRemoved(
107
+ previousBondState = BluetoothDevice.BOND_BONDING,
108
+ bondState = BluetoothDevice.BOND_NONE,
109
+ killed = false,
110
+ unpairInProgress = false,
111
+ manualPairing = false,
112
+ msSinceAutoReconnectLink = 600L,
113
+ secureFirmware = true,
114
+ )
115
+ )
116
+ }
117
+
118
+ @Test
119
+ fun pauseIsTheWindowPlusTheCloseMarginWithNo300sClamp() {
120
+ assertEquals(150_000L, MentraLiveOwnerLossPolicy.yieldMs(120_000L))
121
+ // The glasses own the window: a longer one is paused for in full, not clamped to 300 s.
122
+ assertEquals(930_000L, MentraLiveOwnerLossPolicy.yieldMs(900_000L))
123
+ // A short window is not stretched to a floor either.
124
+ assertEquals(40_000L, MentraLiveOwnerLossPolicy.yieldMs(10_000L))
125
+ // A missing window falls back to the 120 s default.
126
+ assertEquals(150_000L, MentraLiveOwnerLossPolicy.yieldMs(0L))
127
+ }
128
+
129
+ @Test
130
+ fun aRestartedAppKeepsOnlyALiveDeadlineWithinThePersistedPause() {
131
+ val now = 1_000_000L
132
+ val pause = MentraLiveOwnerLossPolicy.yieldMs(120_000L)
133
+ assertEquals(0L, MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(0L, now, pause))
134
+ assertEquals(50_000L, MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(now + 50_000L, now, pause))
135
+ assertEquals(pause, MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(now + pause, now, pause))
136
+ assertEquals(0L, MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(now - 1L, now, pause))
137
+ // Further out than the pause that was recorded with it: a clock change, not a window.
138
+ assertEquals(0L, MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(now + pause + 1L, now, pause))
139
+ assertEquals(0L, MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(now + 1_000L, now, 0L))
140
+ }
141
+
142
+ @Test
143
+ fun theWindowRefusalStatus0x14IsNeverARejection() {
144
+ assertFalse(gattRejection(0x14))
145
+ assertFalse(MentraLiveOwnerLossPolicy.isOwnerRejectionStatus(0x14))
146
+ }
147
+
148
+ @Test
149
+ fun pairSelectGoesOnlyWithAnExplicitAttemptToSecureGlasses() {
150
+ assertTrue(MentraLiveOwnerLossPolicy.shouldSendPairSelect(explicitPairing = true, securePairingTarget = true))
151
+ assertFalse(MentraLiveOwnerLossPolicy.shouldSendPairSelect(explicitPairing = false, securePairingTarget = true))
152
+ assertFalse(MentraLiveOwnerLossPolicy.shouldSendPairSelect(explicitPairing = true, securePairingTarget = false))
153
+ assertFalse(MentraLiveOwnerLossPolicy.shouldSendPairSelect(explicitPairing = false, securePairingTarget = false))
154
+ }
155
+
156
+ @Test
157
+ fun parkedSecureReadinessAlwaysHasABondWaitDeadline() {
158
+ // Nothing (no awaiting_confirm any more) suspends the wait on an explicit secure pair.
159
+ assertTrue(MentraLiveOwnerLossPolicy.shouldRunSecureBondWait(explicitPairing = true, securePairingTarget = true))
160
+ assertFalse(MentraLiveOwnerLossPolicy.shouldRunSecureBondWait(explicitPairing = false, securePairingTarget = true))
161
+ assertFalse(MentraLiveOwnerLossPolicy.shouldRunSecureBondWait(explicitPairing = true, securePairingTarget = false))
162
+ assertEquals(40_000L, MentraLiveOwnerLossPolicy.SECURE_BOND_WAIT_MS)
163
+ }
164
+
165
+ @Test
166
+ fun explicitSecurePairDefersCtkdUntilPairingInfoArrives() {
167
+ assertTrue(
168
+ MentraLiveOwnerLossPolicy.shouldDeferCtkdBond(
169
+ explicitPairing = true,
170
+ secureTarget = true,
171
+ bondState = BluetoothDevice.BOND_NONE,
172
+ pairingInfoSeen = false,
173
+ ),
174
+ )
175
+ assertFalse(
176
+ MentraLiveOwnerLossPolicy.shouldDeferCtkdBond(
177
+ explicitPairing = true,
178
+ secureTarget = true,
179
+ bondState = BluetoothDevice.BOND_NONE,
180
+ pairingInfoSeen = true,
181
+ ),
182
+ )
183
+ assertFalse(
184
+ MentraLiveOwnerLossPolicy.shouldDeferCtkdBond(
185
+ explicitPairing = false,
186
+ secureTarget = true,
187
+ bondState = BluetoothDevice.BOND_NONE,
188
+ pairingInfoSeen = false,
189
+ ),
190
+ )
191
+ }
192
+
193
+ @Test
194
+ fun aClearedPairingFlagOnTheSavedGlassesIsNotHiddenFromThePauseWatchScan() {
195
+ assertFalse(
196
+ MentraLiveOwnerLossPolicy.shouldExposeAsNonPairable(
197
+ savedGlasses = true,
198
+ reconnectScan = false,
199
+ secureTrailer = true,
200
+ pairingDiscoverable = false,
201
+ )
202
+ )
203
+ assertTrue(
204
+ MentraLiveOwnerLossPolicy.shouldExposeAsNonPairable(
205
+ savedGlasses = false,
206
+ reconnectScan = false,
207
+ secureTrailer = true,
208
+ pairingDiscoverable = false,
209
+ )
210
+ )
211
+ assertFalse(
212
+ MentraLiveOwnerLossPolicy.shouldExposeAsNonPairable(
213
+ savedGlasses = false,
214
+ reconnectScan = false,
215
+ secureTrailer = true,
216
+ pairingDiscoverable = true,
217
+ )
218
+ )
219
+ }
220
+
221
+ @Test
222
+ fun automaticReconnectStandsDownWhileTheSavedGlassesAdvertisePairingMode() {
223
+ assertTrue(
224
+ MentraLiveOwnerLossPolicy.shouldStandDownInsteadOfReconnect(
225
+ pairingMode = true,
226
+ savedGlasses = true,
227
+ explicitPairing = false,
228
+ )
229
+ )
230
+ assertFalse(
231
+ MentraLiveOwnerLossPolicy.shouldStandDownInsteadOfReconnect(
232
+ pairingMode = true,
233
+ savedGlasses = true,
234
+ explicitPairing = true,
235
+ )
236
+ )
237
+ assertFalse(
238
+ MentraLiveOwnerLossPolicy.shouldStandDownInsteadOfReconnect(
239
+ pairingMode = false,
240
+ savedGlasses = true,
241
+ explicitPairing = false,
242
+ )
243
+ )
244
+ assertFalse(
245
+ MentraLiveOwnerLossPolicy.shouldStandDownInsteadOfReconnect(
246
+ pairingMode = true,
247
+ savedGlasses = false,
248
+ explicitPairing = false,
249
+ )
250
+ )
251
+ }
252
+
253
+ @Test
254
+ fun secureTargetIsNotReadyUntilBondedAndOwnerCommitted() {
255
+ assertTrue(defer(bond = BluetoothDevice.BOND_BONDING, committed = true))
256
+ assertTrue(defer(bond = BluetoothDevice.BOND_NONE, committed = true))
257
+ assertTrue(defer(bond = BluetoothDevice.BOND_NONE, committed = false))
258
+ assertFalse(defer(bond = BluetoothDevice.BOND_BONDED, committed = true))
259
+ assertFalse(defer(secure = false, bond = BluetoothDevice.BOND_NONE, committed = false))
260
+ }
261
+
262
+ @Test
263
+ fun staleBondWithoutOwnerCommitStillDefersAnExplicitSecurePair() {
264
+ // A leftover BOND_BONDED (or a Classic-only bond on the same public address) is not
265
+ // proof that the glasses committed this phone as owner.
266
+ assertTrue(defer(bond = BluetoothDevice.BOND_BONDED, committed = false))
267
+ }
268
+
269
+ @Test
270
+ fun bondedAndOwnerCommittedIsNotDeferred() {
271
+ assertFalse(defer(bond = BluetoothDevice.BOND_BONDED, committed = true))
272
+ }
273
+
274
+ @Test
275
+ fun lateOwnerCommitAfterBondAllowsReady() {
276
+ // glasses_ready and the bond arrive first: parked.
277
+ assertTrue(defer(bond = BluetoothDevice.BOND_BONDED, committed = false))
278
+ // The late pairing_info carries owner_committed: ready is allowed now.
279
+ assertTrue(MentraLiveOwnerLossPolicy.pairingInfoConfirmsOwner(true))
280
+ assertFalse(defer(bond = BluetoothDevice.BOND_BONDED, committed = true))
281
+ }
282
+
283
+ @Test
284
+ fun pairingInfoWithoutOwnerCommitDoesNotConfirmOwner() {
285
+ assertFalse(MentraLiveOwnerLossPolicy.pairingInfoConfirmsOwner(false))
286
+ assertTrue(MentraLiveOwnerLossPolicy.pairingInfoConfirmsOwner(true))
287
+ }
288
+
289
+ @Test
290
+ fun legacyTargetIsNeverGated() {
291
+ for (bond in listOf(BluetoothDevice.BOND_NONE, BluetoothDevice.BOND_BONDING, null)) {
292
+ for (committed in listOf(true, false)) {
293
+ assertFalse(defer(secure = false, bond = bond, committed = committed))
294
+ assertTrue(audioReady(secure = false, bond = bond, committed = committed))
295
+ }
296
+ }
297
+ }
298
+
299
+ @Test
300
+ fun classicAudioDoesNotCommitAnUnbondedSecurePair() {
301
+ assertFalse(audioReady(bond = BluetoothDevice.BOND_BONDING, committed = false))
302
+ assertFalse(audioReady(bond = BluetoothDevice.BOND_NONE, committed = false))
303
+ assertTrue(audioReady(bond = BluetoothDevice.BOND_BONDED, committed = true))
304
+ }
305
+
306
+ @Test
307
+ fun classicAudioFirstWithoutOwnerCommitDoesNotMarkReadyDuringExplicitPairing() {
308
+ assertFalse(audioReady(bond = BluetoothDevice.BOND_BONDED, committed = false))
309
+ // Once the commit arrives the same audio-first state may complete.
310
+ assertTrue(audioReady(bond = BluetoothDevice.BOND_BONDED, committed = true))
311
+ }
312
+
313
+ @Test
314
+ fun reconnectOutsideExplicitPairingIsNeverGated() {
315
+ for (bond in listOf(BluetoothDevice.BOND_NONE, BluetoothDevice.BOND_BONDING, null)) {
316
+ for (committed in listOf(true, false)) {
317
+ assertTrue(audioReady(explicit = false, bond = bond, committed = committed))
318
+ }
319
+ }
320
+ }
321
+
322
+ private fun defer(
323
+ secure: Boolean = true,
324
+ bond: Int?,
325
+ committed: Boolean,
326
+ ): Boolean = MentraLiveOwnerLossPolicy.shouldDeferReadyForBond(secure, bond, committed)
327
+
328
+ private fun audioReady(
329
+ explicit: Boolean = true,
330
+ secure: Boolean = true,
331
+ bond: Int?,
332
+ committed: Boolean,
333
+ ): Boolean =
334
+ MentraLiveOwnerLossPolicy.shouldMarkReadyFromAudio(
335
+ explicitPairing = explicit,
336
+ securePairingTarget = secure,
337
+ bondState = bond,
338
+ ownerCommitted = committed,
339
+ )
340
+
341
+ private fun gattRejection(
342
+ status: Int,
343
+ manualPairing: Boolean = false,
344
+ secureFirmware: Boolean = true,
345
+ ): Boolean =
346
+ MentraLiveOwnerLossPolicy.isOwnerRejectionOnGattError(
347
+ status,
348
+ manualPairing,
349
+ secureFirmware,
350
+ )
351
+
352
+ private fun bondRemoved(
353
+ msSinceAutoReconnectLink: Long?,
354
+ killed: Boolean = false,
355
+ unpairInProgress: Boolean = false,
356
+ manualPairing: Boolean = false,
357
+ secureFirmware: Boolean = true,
358
+ ): Boolean =
359
+ MentraLiveOwnerLossPolicy.isOwnerRejectionOnBondRemoved(
360
+ previousBondState = BluetoothDevice.BOND_BONDED,
361
+ bondState = BluetoothDevice.BOND_NONE,
362
+ killed = killed,
363
+ unpairInProgress = unpairInProgress,
364
+ manualPairing = manualPairing,
365
+ msSinceAutoReconnectLink = msSinceAutoReconnectLink,
366
+ secureFirmware = secureFirmware,
367
+ )
368
+ }
@@ -81,6 +81,26 @@ class MentraLivePairingAdvertisementParserTest {
81
81
  assertFalse(result?.pairingMode == true)
82
82
  }
83
83
 
84
+ @Test
85
+ fun readsTheOwnerChangedFlagAndWindowIdFromTheV4Trailer() {
86
+ val payload = securePayload(pairingFlag = 0)
87
+ payload[6] = 4
88
+ payload[7] = 0x05
89
+ payload[12] = 0xC8.toByte()
90
+ val result = MentraLivePairingAdvertisementParser.parse(payload)
91
+
92
+ assertTrue(result?.ownerChanged == true)
93
+ assertEquals(200, result?.pairingGeneration)
94
+ assertFalse(result?.pairingMode == true)
95
+ }
96
+
97
+ @Test
98
+ fun ownerChangedIsFalseWithoutTheFlag() {
99
+ val result = MentraLivePairingAdvertisementParser.parse(securePayload(pairingFlag = 1))
100
+
101
+ assertFalse(result?.ownerChanged == true)
102
+ }
103
+
84
104
  @Test
85
105
  fun rejectsLegacyPayloadThatMatchesOldVersionCapabilityHeuristic() {
86
106
  val legacyPayload = ByteArray(27)
@@ -0,0 +1,136 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothDevice
4
+ import com.mentra.bluetoothsdk.sgcs.MentraLivePairingReadinessPolicy.Readiness
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNotEquals
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class MentraLivePairingReadinessPolicyTest {
13
+ private val bonded = BluetoothDevice.BOND_BONDED
14
+ private val unbonded = BluetoothDevice.BOND_NONE
15
+
16
+ @Test
17
+ fun `constants match the spec`() {
18
+ assertEquals(5_000L, MentraLivePairingReadinessPolicy.UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS)
19
+ assertEquals(15_000L, MentraLivePairingReadinessPolicy.SECURE_FIRMWARE_REFRESH_MS)
20
+ }
21
+
22
+ @Test
23
+ fun `the selected row decides and session pairing_info only fills an unknown row`() {
24
+ val policy = MentraLivePairingReadinessPolicy
25
+ assertTrue(policy.isSecurePairingTarget(true, null))
26
+ assertTrue(policy.isSecurePairingTarget(true, false))
27
+ // A row listed as legacy stays legacy (a rolled-back unit is never re-read as secure).
28
+ assertFalse(policy.isSecurePairingTarget(false, true))
29
+ assertFalse(policy.isSecurePairingTarget(false, null))
30
+ assertTrue(policy.isSecurePairingTarget(null, true))
31
+ assertFalse(policy.isSecurePairingTarget(null, false))
32
+ assertFalse(policy.isSecurePairingTarget(null, null))
33
+ }
34
+
35
+ @Test
36
+ fun `automatic reconnects are never gated`() {
37
+ for (selected in listOf(true, false, null)) for (session in listOf(true, false, null)) {
38
+ assertEquals(
39
+ Readiness.READY,
40
+ readiness(explicit = false, selected = selected, session = session, bond = unbonded),
41
+ )
42
+ }
43
+ }
44
+
45
+ @Test
46
+ fun `unknown capability waits for pairing_info, then is legacy and ready without one`() {
47
+ assertEquals(Readiness.AWAIT_PAIRING_INFO, readiness(selected = null, session = null))
48
+ assertEquals(Readiness.READY, readiness(selected = null, session = null, expired = true))
49
+ }
50
+
51
+ @Test
52
+ fun `unknown capability with a secure pairing_info follows the secure rule`() {
53
+ assertEquals(Readiness.AWAIT_OWNER_COMMIT, readiness(selected = null, session = true, bond = unbonded))
54
+ assertEquals(
55
+ Readiness.AWAIT_OWNER_COMMIT,
56
+ readiness(selected = null, session = true, bond = bonded, committed = false),
57
+ )
58
+ assertEquals(Readiness.READY, readiness(selected = null, session = true, bond = bonded, committed = true))
59
+ // A late secure report after the wait expired still applies the secure rule.
60
+ assertEquals(
61
+ Readiness.AWAIT_OWNER_COMMIT,
62
+ readiness(selected = null, session = true, expired = true, bond = unbonded),
63
+ )
64
+ }
65
+
66
+ @Test
67
+ fun `unknown capability with a legacy pairing_info is ready at once`() {
68
+ assertEquals(Readiness.READY, readiness(selected = null, session = false, bond = unbonded))
69
+ }
70
+
71
+ @Test
72
+ fun `known rows never wait for pairing_info`() {
73
+ assertEquals(Readiness.AWAIT_OWNER_COMMIT, readiness(selected = true, session = null, bond = unbonded))
74
+ assertEquals(Readiness.READY, readiness(selected = true, session = null, bond = bonded, committed = true))
75
+ assertEquals(Readiness.READY, readiness(selected = false, session = null, bond = unbonded))
76
+ assertEquals(Readiness.READY, readiness(selected = false, session = true, bond = unbonded))
77
+ }
78
+
79
+ @Test
80
+ fun `readiness never waits without a deadline`() {
81
+ for (explicit in listOf(true, false)) for (selected in listOf(true, false, null)) {
82
+ for (session in listOf(true, false, null)) for (expired in listOf(true, false)) {
83
+ for (bond in listOf(bonded, unbonded, BluetoothDevice.BOND_BONDING, null)) {
84
+ for (committed in listOf(true, false)) {
85
+ val result = readiness(explicit, selected, session, expired, bond, committed)
86
+ // The pairing_info wait is bounded: once it expired it never applies.
87
+ if (expired) assertNotEquals(Readiness.AWAIT_PAIRING_INFO, result)
88
+ // The owner-commit wait always runs the secure bond wait deadline.
89
+ if (result == Readiness.AWAIT_OWNER_COMMIT) {
90
+ assertTrue(
91
+ MentraLiveOwnerLossPolicy.shouldRunSecureBondWait(
92
+ explicit,
93
+ MentraLivePairingReadinessPolicy.isSecurePairingTarget(selected, session),
94
+ )
95
+ )
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ @Test
104
+ fun `a ready session without pairing_info clears the secure firmware memory`() {
105
+ assertEquals(false, MentraLivePairingReadinessPolicy.secureFirmwareAfterReadySession(pairingInfoSeen = false))
106
+ assertNull(MentraLivePairingReadinessPolicy.secureFirmwareAfterReadySession(pairingInfoSeen = true))
107
+ }
108
+
109
+ @Test
110
+ fun `secure trailer memory starts empty with each scan`() {
111
+ val memory = MentraLiveSecureTrailerScanMemory()
112
+ memory.noteSecureTrailer("AA:BB:CC:DD:EE:01")
113
+ assertTrue(memory.contains("AA:BB:CC:DD:EE:01"))
114
+ assertFalse(memory.contains("AA:BB:CC:DD:EE:02"))
115
+ assertFalse(memory.contains(null))
116
+ memory.onScanStart()
117
+ assertFalse(memory.contains("AA:BB:CC:DD:EE:01"))
118
+ }
119
+
120
+ private fun readiness(
121
+ explicit: Boolean = true,
122
+ selected: Boolean?,
123
+ session: Boolean?,
124
+ expired: Boolean = false,
125
+ bond: Int? = unbonded,
126
+ committed: Boolean = false,
127
+ ): Readiness =
128
+ MentraLivePairingReadinessPolicy.readiness(
129
+ explicitPairing = explicit,
130
+ selectedCapability = selected,
131
+ sessionPairingInfoSecure = session,
132
+ unknownCapabilityWaitExpired = expired,
133
+ bondState = bond,
134
+ ownerCommitted = committed,
135
+ )
136
+ }