@mentra/bluetooth-sdk 3.4.0-dev.996 → 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,227 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothDevice
4
+
5
+ /**
6
+ * Pure predicates behind Mentra Live pairing-mode pauses and rejection detection (pairing spec
7
+ * "Ownership transfer v4"). [MentraLivePairingStateMachine] owns the state; this object holds
8
+ * the per-signal rules both use. Mirrors iOS `MentraLiveConnectionAttemptPolicy`.
9
+ *
10
+ * Outside a pairing window the glasses reject a phone they no longer bond with at connect time
11
+ * (HCI Authentication Failure). A single rejection is only a hint: a glasses reboot, a stale
12
+ * bond, or a radio hiccup can produce one. Only secure-pairing firmware rejects on purpose, and
13
+ * only a rejection that repeats across automatic reconnects ([OwnerRejectionStreak]) counts.
14
+ */
15
+ internal object MentraLiveOwnerLossPolicy {
16
+ /** `entering_pairing_mode.window_ms` when the glasses omit it. */
17
+ const val DEFAULT_PAIRING_WINDOW_MS = 120_000L
18
+
19
+ /** Covers the glasses' bounded close deferral (30 s) after the window timer. */
20
+ const val PAIRING_RESUME_MARGIN_MS = 30_000L
21
+
22
+ /** A bond the glasses dropped surfaces within a few seconds of the reconnect link. */
23
+ const val BOND_REMOVED_AFTER_RECONNECT_MS = 15_000L
24
+
25
+ /** Consecutive automatic-reconnect rejections before this phone stands down. */
26
+ const val REJECTIONS_TO_STAND_DOWN = 3
27
+
28
+ /** The rejections must keep arriving for about this long, so a glasses reboot is not owner loss. */
29
+ const val MIN_REJECTION_SPAN_MS = 45_000L
30
+
31
+ /** A gap this long between rejections means they are unrelated, not one streak. */
32
+ const val REJECTION_STREAK_GAP_MS = 90_000L
33
+
34
+ /** One failed attempt can report twice (bond removed, then GATT disconnect). Count it once. */
35
+ const val SAME_ATTEMPT_MS = 2_000L
36
+
37
+ /**
38
+ * HCI 0x05 Authentication Failure / 0x0F Unacceptable BD_ADDR from a connect-time rejection.
39
+ * The glasses refuse the previous owner during a window with 0x14, which never counts.
40
+ */
41
+ fun isOwnerRejectionStatus(status: Int): Boolean = status == 0x05 || status == 0x0F
42
+
43
+ /**
44
+ * A connect-time rejection of saved secure glasses. Legacy firmware never rejects a
45
+ * non-owner on purpose, and during an explicit pairing attempt the user is pairing anew.
46
+ * This is one observation; [MentraLivePairingStateMachine.onAuthRejection] decides what it adds up to.
47
+ */
48
+ fun isOwnerRejectionOnGattError(
49
+ status: Int,
50
+ manualPairing: Boolean,
51
+ secureFirmware: Boolean,
52
+ ): Boolean = secureFirmware && !manualPairing && isOwnerRejectionStatus(status)
53
+
54
+ /**
55
+ * Android deletes its BLE keys when the glasses answer an encryption request without them.
56
+ * Right after an automatic reconnect that can mean the glasses forgot this phone. Like
57
+ * [isOwnerRejectionOnGattError] this is one observation, not yet owner loss.
58
+ */
59
+ fun isOwnerRejectionOnBondRemoved(
60
+ previousBondState: Int,
61
+ bondState: Int,
62
+ killed: Boolean,
63
+ unpairInProgress: Boolean,
64
+ manualPairing: Boolean,
65
+ msSinceAutoReconnectLink: Long?,
66
+ secureFirmware: Boolean,
67
+ ): Boolean {
68
+ if (!secureFirmware || killed || unpairInProgress || manualPairing) return false
69
+ if (previousBondState != BluetoothDevice.BOND_BONDED ||
70
+ bondState != BluetoothDevice.BOND_NONE
71
+ ) {
72
+ return false
73
+ }
74
+ val since = msSinceAutoReconnectLink ?: return false
75
+ return since in 0..BOND_REMOVED_AFTER_RECONNECT_MS
76
+ }
77
+
78
+ /**
79
+ * Tracks rejections across automatic reconnects. Owner loss needs [REJECTIONS_TO_STAND_DOWN]
80
+ * attempts in a row that span at least [MIN_REJECTION_SPAN_MS]. Any successful ready session
81
+ * calls [reset].
82
+ */
83
+ class OwnerRejectionStreak {
84
+ private var count = 0
85
+ private var firstAtMs = 0L
86
+ private var lastAtMs = 0L
87
+
88
+ /** Records one rejection and returns true once the streak means owner loss. */
89
+ @Synchronized
90
+ fun record(nowMs: Long): Boolean {
91
+ if (count > 0 && nowMs - lastAtMs <= SAME_ATTEMPT_MS) {
92
+ return reached(nowMs)
93
+ }
94
+ if (count > 0 && nowMs - lastAtMs > REJECTION_STREAK_GAP_MS) {
95
+ count = 0
96
+ }
97
+ if (count == 0) firstAtMs = nowMs
98
+ count++
99
+ lastAtMs = nowMs
100
+ return reached(nowMs)
101
+ }
102
+
103
+ @Synchronized
104
+ fun reset() {
105
+ count = 0
106
+ firstAtMs = 0L
107
+ lastAtMs = 0L
108
+ }
109
+
110
+ private fun reached(nowMs: Long): Boolean =
111
+ count >= REJECTIONS_TO_STAND_DOWN && nowMs - firstAtMs >= MIN_REJECTION_SPAN_MS
112
+ }
113
+
114
+ /**
115
+ * How long automatic reconnect pauses for a window: the window plus the close-deferral
116
+ * margin. No floor beyond a missing value and no upper clamp; the glasses own the window.
117
+ */
118
+ fun yieldMs(windowMs: Long): Long =
119
+ (if (windowMs > 0L) windowMs else DEFAULT_PAIRING_WINDOW_MS) + PAIRING_RESUME_MARGIN_MS
120
+
121
+ /**
122
+ * Remaining pause after a process restart. A missing, expired, or implausible deadline (more
123
+ * than the pause that was persisted with it) returns 0 so the phone reconnects. Mirrors iOS
124
+ * `MentraLiveConnectionAttemptPolicy.persistedYieldRemainingMs`.
125
+ */
126
+ fun persistedYieldRemainingMs(untilWallMs: Long, nowWallMs: Long, pauseMs: Long): Long {
127
+ if (untilWallMs <= 0L || pauseMs <= 0L) return 0L
128
+ val remaining = untilWallMs - nowWallMs
129
+ if (remaining <= 0L || remaining > pauseMs) return 0L
130
+ return remaining
131
+ }
132
+
133
+ /**
134
+ * Automatic reconnect must not take the single BLE slot while the saved glasses advertise
135
+ * pairing mode. An explicit pairing attempt (the new phone, or Pair Again) still connects.
136
+ * Mirrors iOS `MentraLiveConnectionAttemptPolicy.shouldStandDownInsteadOfReconnect`.
137
+ */
138
+ fun shouldStandDownInsteadOfReconnect(
139
+ pairingMode: Boolean,
140
+ savedGlasses: Boolean,
141
+ explicitPairing: Boolean,
142
+ ): Boolean = pairingMode && savedGlasses && !explicitPairing
143
+
144
+ /**
145
+ * A discovery scan lists other secure glasses that are not in pairing mode as non-pairable
146
+ * and does not connect to them. The saved glasses are the exception: the pause watch scan
147
+ * has to read their trailer, so a cleared pairing flag ends the pause and reconnects.
148
+ * Mirrors iOS, which skips this return when the advertisement is the saved device.
149
+ */
150
+ fun shouldExposeAsNonPairable(
151
+ savedGlasses: Boolean,
152
+ reconnectScan: Boolean,
153
+ secureTrailer: Boolean,
154
+ pairingDiscoverable: Boolean,
155
+ ): Boolean = !savedGlasses && !reconnectScan && secureTrailer && !pairingDiscoverable
156
+
157
+ /**
158
+ * `secure_pairing_capable` only means the firmware can do secure pairing. Ownership is the
159
+ * separate `owner_committed` bit the glasses set after the BLE bond. Mirrors iOS
160
+ * `MentraLiveConnectionAttemptPolicy.pairingInfoConfirmsOwner`.
161
+ */
162
+ fun pairingInfoConfirmsOwner(ownerCommitted: Boolean): Boolean = ownerCommitted
163
+
164
+ /**
165
+ * Secure glasses commit an owner only on a BLE bond, and announce it with `pairing_info`
166
+ * `owner_committed`. A stale BOND_BONDED (or a Classic-only bond on the same public address)
167
+ * does not prove the glasses committed this phone, so readiness inside the open window needs
168
+ * both the bond and the owner commit. Callers apply this only to explicit pairing attempts;
169
+ * non-secure (legacy) targets are never gated.
170
+ */
171
+ fun shouldDeferReadyForBond(
172
+ securePairingTarget: Boolean,
173
+ bondState: Int?,
174
+ ownerCommitted: Boolean,
175
+ ): Boolean =
176
+ securePairingTarget &&
177
+ (bondState != BluetoothDevice.BOND_BONDED ||
178
+ !pairingInfoConfirmsOwner(ownerCommitted))
179
+
180
+ /** How long an explicit secure pair may wait for a bond result before the attempt fails. */
181
+ const val SECURE_BOND_WAIT_MS = 40_000L
182
+
183
+ /**
184
+ * Readiness parked by [shouldDeferReadyForBond] always has a deadline: an explicit secure
185
+ * attempt arms the bond wait, so a missing bond broadcast or owner commit fails the pair
186
+ * instead of leaving it loading. Mirrors iOS `shouldRunSecurePairingWait`.
187
+ */
188
+ fun shouldRunSecureBondWait(explicitPairing: Boolean, securePairingTarget: Boolean): Boolean =
189
+ explicitPairing && securePairingTarget
190
+
191
+ /**
192
+ * An explicit secure pair must not call `createBond` before `pairing_info` arrives: the
193
+ * glasses ask for security themselves once they know the link, and an early SMP races it.
194
+ */
195
+ fun shouldDeferCtkdBond(
196
+ explicitPairing: Boolean,
197
+ secureTarget: Boolean,
198
+ bondState: Int,
199
+ pairingInfoSeen: Boolean,
200
+ ): Boolean =
201
+ explicitPairing &&
202
+ secureTarget &&
203
+ bondState != BluetoothDevice.BOND_BONDED &&
204
+ !pairingInfoSeen
205
+
206
+ /**
207
+ * `pair_select` tells the glasses this link is the user's explicit choice (pairing spec V4).
208
+ * It goes out once per GATT session, only on an explicit attempt to secure glasses, never on
209
+ * an automatic reconnect. Legacy firmware has no use for it.
210
+ */
211
+ fun shouldSendPairSelect(explicitPairing: Boolean, securePairingTarget: Boolean): Boolean =
212
+ explicitPairing && securePairingTarget
213
+
214
+ /**
215
+ * Classic audio can come up before the BLE bond or the owner commit. During an explicit
216
+ * secure pair that must not save this phone. A reconnect of an already-paired phone can still
217
+ * mark ready.
218
+ */
219
+ fun shouldMarkReadyFromAudio(
220
+ explicitPairing: Boolean,
221
+ securePairingTarget: Boolean,
222
+ bondState: Int?,
223
+ ownerCommitted: Boolean,
224
+ ): Boolean =
225
+ !explicitPairing ||
226
+ !shouldDeferReadyForBond(securePairingTarget, bondState, ownerCommitted)
227
+ }
@@ -3,6 +3,15 @@ package com.mentra.bluetoothsdk.sgcs
3
3
  internal data class MentraLivePairingAdvertisement(
4
4
  val pairingMode: Boolean,
5
5
  val pairingCode: String,
6
+ /**
7
+ * The glasses' `window_id`: moves on each pairing-mode entry. Null when the byte is absent.
8
+ */
9
+ val pairingGeneration: Int? = null,
10
+ /**
11
+ * Trailer flag `OWNER_CHANGED`: the window [pairingGeneration] committed a different phone.
12
+ * Unauthenticated, so it only triggers one verification reconnect.
13
+ */
14
+ val ownerChanged: Boolean = false,
6
15
  )
7
16
 
8
17
  internal object MentraLivePairingAdvertisementParser {
@@ -14,9 +23,11 @@ internal object MentraLivePairingAdvertisementParser {
14
23
  private const val CODE_HIGH_OFFSET = CODE_LOW_OFFSET + 1
15
24
  private const val MAGIC_FIRST_OFFSET = CODE_HIGH_OFFSET + 1
16
25
  private const val MAGIC_SECOND_OFFSET = MAGIC_FIRST_OFFSET + 1
26
+ private const val PAIRING_GENERATION_OFFSET = MAGIC_SECOND_OFFSET + 1
17
27
  private const val PROTOCOL_VERSION_MIN = 2
18
28
  private const val PROTOCOL_VERSION_MAX = 15
19
29
  private const val SECURE_PAIRING_CAPABILITY = 0x01
30
+ private const val OWNER_CHANGED_FLAG = 0x04
20
31
  private const val MAGIC_FIRST = 0x4D // M
21
32
  private const val MAGIC_SECOND = 0x50 // P
22
33
 
@@ -46,9 +57,17 @@ internal object MentraLivePairingAdvertisementParser {
46
57
 
47
58
  val codeLow = manufacturerData[CODE_LOW_OFFSET].toInt() and 0xFF
48
59
  val codeHigh = manufacturerData[CODE_HIGH_OFFSET].toInt() and 0xFF
60
+ val pairingGeneration =
61
+ if (manufacturerData.size > PAIRING_GENERATION_OFFSET) {
62
+ manufacturerData[PAIRING_GENERATION_OFFSET].toInt() and 0xFF
63
+ } else {
64
+ null
65
+ }
49
66
  return MentraLivePairingAdvertisement(
50
67
  pairingMode = manufacturerData[PAIRING_FLAG_OFFSET] == PAIRING_DISCOVERABLE,
51
68
  pairingCode = String.format("%02X%02X", codeHigh, codeLow),
69
+ pairingGeneration = pairingGeneration,
70
+ ownerChanged = (capability and OWNER_CHANGED_FLAG) != 0,
52
71
  )
53
72
  }
54
73
  }
@@ -0,0 +1,94 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Pure rules for when an explicit Mentra Live pair reports ready, and for the saved secure
5
+ * firmware memory. [MentraLive] feeds them the selected scan row, this GATT session's
6
+ * `pairing_info` and the bond state. Readiness never waits without a deadline: the owner-commit
7
+ * wait has [MentraLiveOwnerLossPolicy.SECURE_BOND_WAIT_MS], the unknown-capability wait has
8
+ * [UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS].
9
+ */
10
+ internal object MentraLivePairingReadinessPolicy {
11
+ /**
12
+ * How long an explicit pair to glasses of unknown secure capability waits after
13
+ * `glasses_ready` for `pairing_info` before treating them as legacy firmware.
14
+ */
15
+ const val UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS = 5_000L
16
+
17
+ /** A ready session without `pairing_info` within this time means the glasses are not secure firmware. */
18
+ const val SECURE_FIRMWARE_REFRESH_MS = 15_000L
19
+
20
+ enum class Readiness {
21
+ /** Report the glasses ready now. */
22
+ READY,
23
+ /** The selected glasses' capability is unknown and no `pairing_info` has arrived yet. */
24
+ AWAIT_PAIRING_INFO,
25
+ /** Secure target: wait for the BLE bond and `pairing_info.owner_committed`. */
26
+ AWAIT_OWNER_COMMIT,
27
+ }
28
+
29
+ /**
30
+ * Whether an explicit pair targets secure-pairing firmware. The selected scan row's
31
+ * capability decides, never an address or name seen earlier; when the row did not know it,
32
+ * this session's `pairing_info` does.
33
+ *
34
+ * @param selectedCapability `pending_device_secure_pairing_capable`, null when unknown
35
+ * @param sessionPairingInfoSecure `secure_pairing_capable` from this session's `pairing_info`,
36
+ * null before one arrives
37
+ */
38
+ fun isSecurePairingTarget(selectedCapability: Boolean?, sessionPairingInfoSecure: Boolean?): Boolean =
39
+ selectedCapability ?: (sessionPairingInfoSecure == true)
40
+
41
+ /**
42
+ * Readiness after `glasses_ready`. Automatic reconnects are never gated. An explicit pair to
43
+ * glasses of unknown capability waits for `pairing_info` until [unknownCapabilityWaitExpired];
44
+ * a secure one then follows the owner-commit rule, a legacy one (or none) is ready.
45
+ */
46
+ fun readiness(
47
+ explicitPairing: Boolean,
48
+ selectedCapability: Boolean?,
49
+ sessionPairingInfoSecure: Boolean?,
50
+ unknownCapabilityWaitExpired: Boolean,
51
+ bondState: Int?,
52
+ ownerCommitted: Boolean,
53
+ ): Readiness {
54
+ if (!explicitPairing) return Readiness.READY
55
+ if (selectedCapability == null &&
56
+ sessionPairingInfoSecure == null &&
57
+ !unknownCapabilityWaitExpired
58
+ ) {
59
+ return Readiness.AWAIT_PAIRING_INFO
60
+ }
61
+ val secure = isSecurePairingTarget(selectedCapability, sessionPairingInfoSecure)
62
+ return if (MentraLiveOwnerLossPolicy.shouldDeferReadyForBond(secure, bondState, ownerCommitted)) {
63
+ Readiness.AWAIT_OWNER_COMMIT
64
+ } else {
65
+ Readiness.READY
66
+ }
67
+ }
68
+
69
+ /**
70
+ * `mentra_live_secure_firmware` once a ready session's [SECURE_FIRMWARE_REFRESH_MS] has
71
+ * passed: false when no `pairing_info` arrived, so glasses rolled back to old firmware stop
72
+ * counting as secure. Null leaves the value a `pairing_info` already wrote.
73
+ */
74
+ fun secureFirmwareAfterReadySession(pairingInfoSeen: Boolean): Boolean? =
75
+ if (pairingInfoSeen) null else false
76
+ }
77
+
78
+ /**
79
+ * Addresses advertising the secure-pairing trailer in the current scan. It starts empty with each
80
+ * scan, so glasses rolled back to old firmware stop counting as secure on the next scan. It is a
81
+ * hint for automatic sessions only; an explicit pair uses the selected row's capability
82
+ * ([MentraLivePairingReadinessPolicy.isSecurePairingTarget]).
83
+ */
84
+ internal class MentraLiveSecureTrailerScanMemory {
85
+ private val addresses: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
86
+
87
+ fun onScanStart() = addresses.clear()
88
+
89
+ fun noteSecureTrailer(address: String) {
90
+ addresses.add(address)
91
+ }
92
+
93
+ fun contains(address: String?): Boolean = address != null && addresses.contains(address)
94
+ }