@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,411 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Public Mentra Live pairing state (`MentraLivePairingState` in BluetoothSdk.types.ts).
5
+ * Mirrors iOS `MentraLivePairingState`.
6
+ */
7
+ internal sealed class MentraLivePairingState {
8
+ object Normal : MentraLivePairingState()
9
+
10
+ /** The glasses opened a pairing window. Automatic reconnect waits until [endsAtMs] (wall clock). */
11
+ data class PairingMode(val windowId: Int, val endsAtMs: Long) : MentraLivePairingState()
12
+
13
+ /** The glasses no longer accept this phone. Only an explicit pair or a forget clears it. */
14
+ object Rejected : MentraLivePairingState()
15
+
16
+ fun toMap(): Map<String, Any> =
17
+ when (this) {
18
+ Normal -> mapOf("state" to "normal")
19
+ is PairingMode ->
20
+ mapOf("state" to "pairing_mode", "windowId" to windowId, "endsAtMs" to endsAtMs)
21
+ Rejected -> mapOf("state" to "pairing_rejected")
22
+ }
23
+ }
24
+
25
+ /** What the connection manager must do after a [MentraLivePairingStateMachine] input. */
26
+ internal enum class MentraLivePairingEffect {
27
+ NONE,
28
+ /** Leave the link and make no automatic connection until resumed. */
29
+ PAUSE,
30
+ /** The window is over: reconnect to the saved glasses. */
31
+ RESUME,
32
+ /** Stop reconnecting. The glasses belong to another phone. */
33
+ REJECT,
34
+ }
35
+
36
+ internal data class MentraLivePairingTransition(
37
+ val effect: MentraLivePairingEffect,
38
+ val stateChanged: Boolean,
39
+ val note: String,
40
+ )
41
+
42
+ /**
43
+ * Pairing-mode pause/resume, takeover detection and rejection detection for one saved Mentra Live
44
+ * (pairing spec "Ownership transfer v4"). Pure and clock-free: every input carries `nowMs`
45
+ * (wall clock, because the pause deadline survives a process restart). Mirrors iOS
46
+ * `MentraLivePairingStateMachine` input for input.
47
+ */
48
+ internal class MentraLivePairingStateMachine {
49
+ companion object {
50
+ /** Glasses flag-clear advertisements seen this soon after the notice can predate the window. */
51
+ const val FLAG_CLEAR_GRACE_MS = 3_000L
52
+
53
+ /** A pairing-mode advertisement this recent still means the glasses are in pairing mode. */
54
+ const val PAIRING_ADVERT_FRESH_MS = 10_000L
55
+
56
+ /** Window id used when protocol-3 firmware sends `owner_retained` without `window_id`. */
57
+ const val UNKNOWN_WINDOW_ID = -1
58
+
59
+ const val KEY_DEVICE_ID = "pairing_v4_device_id"
60
+ const val KEY_STATE = "pairing_v4_state"
61
+ const val KEY_WINDOW_ID = "pairing_v4_window_id"
62
+ const val KEY_ENDS_AT_MS = "pairing_v4_ends_at_ms"
63
+ const val KEY_PAUSE_MS = "pairing_v4_pause_ms"
64
+ const val KEY_ENDED_WINDOW_ID = "pairing_v4_ended_window_id"
65
+ const val KEY_VERIFIED_WINDOW_ID = "pairing_v4_verified_window_id"
66
+ val KEYS =
67
+ listOf(
68
+ KEY_DEVICE_ID,
69
+ KEY_STATE,
70
+ KEY_WINDOW_ID,
71
+ KEY_ENDS_AT_MS,
72
+ KEY_PAUSE_MS,
73
+ KEY_ENDED_WINDOW_ID,
74
+ KEY_VERIFIED_WINDOW_ID,
75
+ )
76
+
77
+ /** Pre-v4 stand-down phase. Its presence migrates to [MentraLivePairingState.Rejected]. */
78
+ const val LEGACY_REJECTION_PHASE_KEY = "previous_owner_rejection_phase"
79
+ val LEGACY_KEYS =
80
+ listOf(
81
+ LEGACY_REJECTION_PHASE_KEY,
82
+ "previous_owner_rejection_generation",
83
+ "pairing_yield_until_wall_ms",
84
+ )
85
+
86
+ private const val STATE_PAIRING_MODE = "pairing_mode"
87
+ private const val STATE_REJECTED = "pairing_rejected"
88
+
89
+ private fun intValue(value: Any?): Int? = (value as? Number)?.toInt()
90
+
91
+ private fun longValue(value: Any?): Long? = (value as? Number)?.toLong()
92
+ }
93
+
94
+ var state: MentraLivePairingState = MentraLivePairingState.Normal
95
+ private set
96
+
97
+ /** Saved glasses this record belongs to. Empty until known. */
98
+ var deviceId: String = ""
99
+ private set
100
+
101
+ /** The next connection attempt is the one verification after an `OWNER_CHANGED` advertisement. */
102
+ var verificationArmed: Boolean = false
103
+ private set
104
+
105
+ /** The window this phone paused for and that has ended. Read against `OWNER_CHANGED`. */
106
+ var endedWindowId: Int? = null
107
+ private set
108
+
109
+ private var pauseMs = 0L
110
+ private var pausedAtMs: Long? = null
111
+ private var verifiedWindowId: Int? = null
112
+ private var lastPairingAdvertAtMs: Long? = null
113
+ private var takeoverAdvertRead = false
114
+ private val streak = MentraLiveOwnerLossPolicy.OwnerRejectionStreak()
115
+
116
+ val allowsAutomaticReconnect: Boolean
117
+ get() = state == MentraLivePairingState.Normal
118
+
119
+ /**
120
+ * A window this phone paused for has ended and no post-window advertisement has been read,
121
+ * so a reconnect should scan first instead of dialing the saved address directly.
122
+ */
123
+ val awaitingTakeoverCheck: Boolean
124
+ get() = state == MentraLivePairingState.Normal && endedWindowId != null && !takeoverAdvertRead
125
+
126
+ val isPaused: Boolean
127
+ get() = state is MentraLivePairingState.PairingMode
128
+
129
+ fun remainingPauseMs(nowMs: Long): Long =
130
+ (state as? MentraLivePairingState.PairingMode)?.let { maxOf(0L, it.endsAtMs - nowMs) } ?: 0L
131
+
132
+ /**
133
+ * Rebuild from persisted values. Unknown keys and malformed values are ignored. A record for
134
+ * other glasses, or a pause whose deadline passed or is implausible, does not survive.
135
+ */
136
+ fun restore(values: Map<String, *>, savedDeviceId: String, nowMs: Long): MentraLivePairingTransition {
137
+ resetInMemory()
138
+ state = MentraLivePairingState.Normal
139
+ deviceId = values[KEY_DEVICE_ID] as? String ?: ""
140
+ endedWindowId = intValue(values[KEY_ENDED_WINDOW_ID])
141
+ verifiedWindowId = intValue(values[KEY_VERIFIED_WINDOW_ID])
142
+ if (savedDeviceId.isNotEmpty() && deviceId.isNotEmpty() && deviceId != savedDeviceId) {
143
+ clearRecord()
144
+ deviceId = savedDeviceId
145
+ return none("record belongs to other glasses")
146
+ }
147
+ when (values[KEY_STATE] as? String) {
148
+ STATE_REJECTED -> {
149
+ state = MentraLivePairingState.Rejected
150
+ return MentraLivePairingTransition(MentraLivePairingEffect.REJECT, true, "restored rejected")
151
+ }
152
+ STATE_PAIRING_MODE -> {
153
+ val windowId = intValue(values[KEY_WINDOW_ID])
154
+ val endsAtMs = longValue(values[KEY_ENDS_AT_MS]) ?: 0L
155
+ val pause =
156
+ longValue(values[KEY_PAUSE_MS])
157
+ ?: MentraLiveOwnerLossPolicy.yieldMs(
158
+ MentraLiveOwnerLossPolicy.DEFAULT_PAIRING_WINDOW_MS
159
+ )
160
+ val remaining = MentraLiveOwnerLossPolicy.persistedYieldRemainingMs(endsAtMs, nowMs, pause)
161
+ if (windowId != null && remaining > 0L) {
162
+ state = MentraLivePairingState.PairingMode(windowId, endsAtMs)
163
+ pauseMs = pause
164
+ return MentraLivePairingTransition(
165
+ MentraLivePairingEffect.PAUSE,
166
+ true,
167
+ "restored pairing_mode window=$windowId remaining=${remaining}ms",
168
+ )
169
+ }
170
+ if (windowId != null) endedWindowId = windowId
171
+ return none("persisted pause ended while the app was away")
172
+ }
173
+ }
174
+ if (values[LEGACY_REJECTION_PHASE_KEY] is String) {
175
+ state = MentraLivePairingState.Rejected
176
+ return MentraLivePairingTransition(MentraLivePairingEffect.REJECT, true, "migrated legacy stand-down")
177
+ }
178
+ return none("restored normal")
179
+ }
180
+
181
+ /** The current record, keyed by [KEYS]. */
182
+ fun persisted(): Map<String, Any> {
183
+ val values = mutableMapOf<String, Any>()
184
+ if (deviceId.isNotEmpty()) values[KEY_DEVICE_ID] = deviceId
185
+ when (val current = state) {
186
+ is MentraLivePairingState.PairingMode -> {
187
+ values[KEY_STATE] = STATE_PAIRING_MODE
188
+ values[KEY_WINDOW_ID] = current.windowId
189
+ values[KEY_ENDS_AT_MS] = current.endsAtMs
190
+ values[KEY_PAUSE_MS] = pauseMs
191
+ }
192
+ MentraLivePairingState.Rejected -> values[KEY_STATE] = STATE_REJECTED
193
+ MentraLivePairingState.Normal -> Unit
194
+ }
195
+ endedWindowId?.let { values[KEY_ENDED_WINDOW_ID] = it }
196
+ // Armed verification is not persisted, so a window counts as verified only once its
197
+ // verification has been used. After a restart the next OWNER_CHANGED advert re-arms it.
198
+ if (!verificationArmed) verifiedWindowId?.let { values[KEY_VERIFIED_WINDOW_ID] = it }
199
+ return values
200
+ }
201
+
202
+ /** An automatic connect to [id]. A record for other glasses no longer applies. */
203
+ fun bindDevice(id: String): MentraLivePairingTransition {
204
+ if (id.isEmpty() || id == deviceId) return none("same glasses")
205
+ val hadRecord = deviceId.isNotEmpty()
206
+ val wasNormal = state == MentraLivePairingState.Normal
207
+ if (hadRecord) clearRecord()
208
+ deviceId = id
209
+ return MentraLivePairingTransition(
210
+ MentraLivePairingEffect.NONE,
211
+ hadRecord && !wasNormal,
212
+ if (hadRecord) "record moved to other glasses" else "bound",
213
+ )
214
+ }
215
+
216
+ /**
217
+ * `entering_pairing_mode`. Protocol 4 (`window_id`) pauses this phone for the window plus a
218
+ * margin. Firmware without ownership transfer forgets this phone on entry (pairing spec R4),
219
+ * so a notice without `window_id` or `owner_retained` means the glasses dropped it.
220
+ */
221
+ fun onEnteringPairingMode(
222
+ windowMs: Long?,
223
+ windowId: Int?,
224
+ ownerRetained: Boolean,
225
+ explicitAttempt: Boolean,
226
+ deviceId: String,
227
+ nowMs: Long,
228
+ ): MentraLivePairingTransition {
229
+ if (deviceId.isNotEmpty()) this.deviceId = deviceId
230
+ if (explicitAttempt) return none("notice during an explicit attempt")
231
+ if (windowId == null && !ownerRetained) return reject("forget_on_entry")
232
+ val id = windowId ?: UNKNOWN_WINDOW_ID
233
+ val current = state
234
+ if (current is MentraLivePairingState.PairingMode && current.windowId == id) {
235
+ return none("duplicate notice for window $id")
236
+ }
237
+ val pause = MentraLiveOwnerLossPolicy.yieldMs(windowMs ?: 0L)
238
+ state = MentraLivePairingState.PairingMode(id, nowMs + pause)
239
+ pauseMs = pause
240
+ pausedAtMs = nowMs
241
+ endedWindowId = null
242
+ verificationArmed = false
243
+ streak.reset()
244
+ return MentraLivePairingTransition(
245
+ MentraLivePairingEffect.PAUSE,
246
+ true,
247
+ "pairing_mode window=$id pause=${pause}ms",
248
+ )
249
+ }
250
+
251
+ /**
252
+ * `pairing_mode_ended`. Only a connected phone receives it, so the glasses still admit this
253
+ * phone and there is no takeover to check.
254
+ */
255
+ fun onPairingModeEnded(reason: String?, windowId: Int?): MentraLivePairingTransition {
256
+ val current = state as? MentraLivePairingState.PairingMode ?: return none("not paused")
257
+ if (windowId != null && current.windowId != UNKNOWN_WINDOW_ID && windowId != current.windowId) {
258
+ return none("ended window $windowId is not ${current.windowId}")
259
+ }
260
+ state = MentraLivePairingState.Normal
261
+ pausedAtMs = null
262
+ endedWindowId = null
263
+ return MentraLivePairingTransition(
264
+ MentraLivePairingEffect.RESUME,
265
+ true,
266
+ "pairing_mode_ended reason=${reason ?: ""}",
267
+ )
268
+ }
269
+
270
+ /**
271
+ * An advertisement from the saved glasses carrying the secure trailer. A cleared pairing flag
272
+ * ends the pause early. After the window this phone paused for, `OWNER_CHANGED` for that
273
+ * window arms exactly one verification attempt.
274
+ */
275
+ fun onAdvertisement(
276
+ pairingMode: Boolean,
277
+ ownerChanged: Boolean,
278
+ generation: Int?,
279
+ nowMs: Long,
280
+ ): MentraLivePairingTransition {
281
+ lastPairingAdvertAtMs = if (pairingMode) nowMs else null
282
+ var effect = MentraLivePairingEffect.NONE
283
+ var changed = false
284
+ val notes = mutableListOf<String>()
285
+ val current = state
286
+ if (current is MentraLivePairingState.PairingMode && !pairingMode) {
287
+ val sincePause = pausedAtMs?.let { nowMs - it } ?: Long.MAX_VALUE
288
+ if (sincePause >= FLAG_CLEAR_GRACE_MS) {
289
+ state = MentraLivePairingState.Normal
290
+ pausedAtMs = null
291
+ endedWindowId = current.windowId
292
+ effect = MentraLivePairingEffect.RESUME
293
+ changed = true
294
+ notes += "pairing flag cleared"
295
+ }
296
+ }
297
+ if (state == MentraLivePairingState.Normal && !pairingMode && endedWindowId != null) {
298
+ takeoverAdvertRead = true
299
+ if (ownerChanged &&
300
+ generation != null &&
301
+ generation == endedWindowId &&
302
+ verifiedWindowId != generation
303
+ ) {
304
+ verificationArmed = true
305
+ verifiedWindowId = generation
306
+ notes += "owner_changed for window $generation — one verification attempt"
307
+ }
308
+ }
309
+ return MentraLivePairingTransition(effect, changed, notes.joinToString("; ").ifEmpty { "advert" })
310
+ }
311
+
312
+ /** The pause deadline may have passed. */
313
+ fun onTick(nowMs: Long): MentraLivePairingTransition {
314
+ val current = state as? MentraLivePairingState.PairingMode ?: return none("not paused")
315
+ if (nowMs < current.endsAtMs) return none("pause continues")
316
+ state = MentraLivePairingState.Normal
317
+ pausedAtMs = null
318
+ endedWindowId = current.windowId
319
+ takeoverAdvertRead = false
320
+ return MentraLivePairingTransition(MentraLivePairingEffect.RESUME, true, "pause deadline passed")
321
+ }
322
+
323
+ /**
324
+ * One authentication-type failure (Android HCI 0x05/0x0F or bond removed, iOS CBError 14 or
325
+ * insufficient authentication/encryption). The intentional disconnect at window entry, the
326
+ * glasses' 0x14 refusals and anything while pairing mode is in effect never count.
327
+ */
328
+ fun onAuthRejection(
329
+ secureFirmware: Boolean,
330
+ explicitAttempt: Boolean,
331
+ nowMs: Long,
332
+ ): MentraLivePairingTransition {
333
+ if (!secureFirmware) return none("legacy firmware")
334
+ if (explicitAttempt) return none("explicit attempt")
335
+ when (state) {
336
+ is MentraLivePairingState.PairingMode -> return none("pairing_mode in effect")
337
+ MentraLivePairingState.Rejected -> return none("already rejected")
338
+ MentraLivePairingState.Normal -> Unit
339
+ }
340
+ if (advertShowsPairingMode(nowMs)) return none("advertisement shows pairing mode")
341
+ if (verificationArmed) {
342
+ return reject("owner_changed_verification_failed")
343
+ }
344
+ if (!streak.record(nowMs)) return none("counted toward the rejection streak")
345
+ return reject("auth_rejection_streak")
346
+ }
347
+
348
+ /** The verification attempt ended without an authentication verdict. */
349
+ fun onConnectAttemptFailed(): MentraLivePairingTransition {
350
+ if (!verificationArmed) return none("no verification")
351
+ verificationArmed = false
352
+ return none("verification attempt failed without an auth verdict")
353
+ }
354
+
355
+ /** A session reached ready. An explicit pair clears [MentraLivePairingState.Rejected]. */
356
+ fun onSessionReady(explicitAttempt: Boolean): MentraLivePairingTransition {
357
+ streak.reset()
358
+ verificationArmed = false
359
+ endedWindowId = null
360
+ lastPairingAdvertAtMs = null
361
+ val current = state
362
+ val clear =
363
+ current is MentraLivePairingState.PairingMode ||
364
+ (explicitAttempt && current == MentraLivePairingState.Rejected)
365
+ if (!clear) return none("ready")
366
+ state = MentraLivePairingState.Normal
367
+ pausedAtMs = null
368
+ return MentraLivePairingTransition(
369
+ MentraLivePairingEffect.NONE,
370
+ true,
371
+ if (explicitAttempt) "explicit pair succeeded" else "glasses admitted this phone",
372
+ )
373
+ }
374
+
375
+ fun onForget(): MentraLivePairingTransition {
376
+ val changed = state != MentraLivePairingState.Normal
377
+ clearRecord()
378
+ deviceId = ""
379
+ return MentraLivePairingTransition(MentraLivePairingEffect.NONE, changed, "forget")
380
+ }
381
+
382
+ private fun advertShowsPairingMode(nowMs: Long): Boolean =
383
+ lastPairingAdvertAtMs?.let { nowMs - it in 0..PAIRING_ADVERT_FRESH_MS } == true
384
+
385
+ private fun reject(reason: String): MentraLivePairingTransition {
386
+ state = MentraLivePairingState.Rejected
387
+ pausedAtMs = null
388
+ endedWindowId = null
389
+ verificationArmed = false
390
+ streak.reset()
391
+ return MentraLivePairingTransition(MentraLivePairingEffect.REJECT, true, reason)
392
+ }
393
+
394
+ private fun clearRecord() {
395
+ resetInMemory()
396
+ state = MentraLivePairingState.Normal
397
+ endedWindowId = null
398
+ verifiedWindowId = null
399
+ }
400
+
401
+ private fun resetInMemory() {
402
+ pauseMs = 0L
403
+ pausedAtMs = null
404
+ verificationArmed = false
405
+ lastPairingAdvertAtMs = null
406
+ takeoverAdvertRead = false
407
+ streak.reset()
408
+ }
409
+
410
+ private fun none(note: String) = MentraLivePairingTransition(MentraLivePairingEffect.NONE, false, note)
411
+ }
@@ -0,0 +1,124 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.content.Context
4
+ import androidx.annotation.VisibleForTesting
5
+ import com.mentra.bluetoothsdk.Bridge
6
+ import com.mentra.bluetoothsdk.DeviceStore
7
+
8
+ /**
9
+ * Process-wide owner of the [MentraLivePairingStateMachine]. It outlives each [MentraLive]
10
+ * instance so a disconnect or SGC replacement keeps the pause and the rejection, persists the
11
+ * record natively (never through the replayed settings), and publishes
12
+ * `GlassesStatus.pairingState` plus `pairing_state_changed`.
13
+ */
14
+ internal object MentraLivePairingStateStore {
15
+ private const val PREFS = "mentra_live_owner"
16
+
17
+ private var machine = MentraLivePairingStateMachine()
18
+ private var restored = false
19
+
20
+ /** Drops the in-memory state as a process restart would. Persisted values stay. */
21
+ @VisibleForTesting
22
+ @Synchronized
23
+ internal fun resetForTest() {
24
+ machine = MentraLivePairingStateMachine()
25
+ restored = false
26
+ }
27
+
28
+ val state: MentraLivePairingState
29
+ @Synchronized get() = machine.state
30
+
31
+ val allowsAutomaticReconnect: Boolean
32
+ @Synchronized get() = machine.allowsAutomaticReconnect
33
+
34
+ val isPaused: Boolean
35
+ @Synchronized get() = machine.isPaused
36
+
37
+ val isRejected: Boolean
38
+ @Synchronized get() = machine.state == MentraLivePairingState.Rejected
39
+
40
+ val verificationArmed: Boolean
41
+ @Synchronized get() = machine.verificationArmed
42
+
43
+ /** A window this phone paused for ended and its advertisement has not been read yet. */
44
+ val awaitingTakeoverCheck: Boolean
45
+ @Synchronized get() = machine.awaitingTakeoverCheck
46
+
47
+ @Synchronized
48
+ fun remainingPauseMs(nowMs: Long = System.currentTimeMillis()): Long = machine.remainingPauseMs(nowMs)
49
+
50
+ /** Restore once per process. Later calls only republish. */
51
+ @Synchronized
52
+ fun ensureRestored(savedDeviceId: String, nowMs: Long = System.currentTimeMillis()): MentraLivePairingTransition? {
53
+ if (restored) {
54
+ publish(emitEvent = false)
55
+ return null
56
+ }
57
+ restored = true
58
+ val prefs = prefs()
59
+ val values = prefs?.all ?: emptyMap<String, Any?>()
60
+ val transition = machine.restore(values, savedDeviceId, nowMs)
61
+ prefs?.edit()?.apply {
62
+ MentraLivePairingStateMachine.LEGACY_KEYS.forEach { remove(it) }
63
+ apply()
64
+ }
65
+ persist()
66
+ publish()
67
+ Bridge.log("PAIRFLOW: pairing_state restore note=${transition.note} state=${describe()}")
68
+ return transition
69
+ }
70
+
71
+ /** Run one machine input, persist the record, and publish when the public state changed. */
72
+ @Synchronized
73
+ fun update(
74
+ source: String,
75
+ input: (MentraLivePairingStateMachine) -> MentraLivePairingTransition,
76
+ ): MentraLivePairingTransition {
77
+ val before = machine.state
78
+ val beforeRecord = machine.persisted()
79
+ val transition = input(machine)
80
+ // Duplicate-reporting scans feed every advertisement through here.
81
+ if (machine.persisted() != beforeRecord) persist()
82
+ if (machine.state != before) publish()
83
+ if (transition.effect != MentraLivePairingEffect.NONE || machine.state != before) {
84
+ Bridge.log(
85
+ "PAIRFLOW: pairing_state source=$source effect=${transition.effect} " +
86
+ "note=${transition.note} state=${describe()}"
87
+ )
88
+ }
89
+ return transition
90
+ }
91
+
92
+ @Synchronized
93
+ fun describe(): String =
94
+ when (val current = machine.state) {
95
+ MentraLivePairingState.Normal -> "normal"
96
+ is MentraLivePairingState.PairingMode ->
97
+ "pairing_mode(window=${current.windowId}, endsAtMs=${current.endsAtMs})"
98
+ MentraLivePairingState.Rejected -> "pairing_rejected"
99
+ }
100
+
101
+ private fun persist() {
102
+ val prefs = prefs() ?: return
103
+ val editor = prefs.edit()
104
+ MentraLivePairingStateMachine.KEYS.forEach { editor.remove(it) }
105
+ machine.persisted().forEach { (key, value) ->
106
+ when (value) {
107
+ is Int -> editor.putInt(key, value)
108
+ is Long -> editor.putLong(key, value)
109
+ is String -> editor.putString(key, value)
110
+ is Boolean -> editor.putBoolean(key, value)
111
+ }
112
+ }
113
+ editor.apply()
114
+ }
115
+
116
+ private fun publish(emitEvent: Boolean = true) {
117
+ val map = machine.state.toMap()
118
+ DeviceStore.apply("glasses", "pairingState", map)
119
+ if (emitEvent) Bridge.sendTypedMessage("pairing_state_changed", mapOf("state" to map))
120
+ }
121
+
122
+ private fun prefs() =
123
+ Bridge.getContext()?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
124
+ }
@@ -1,5 +1,8 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
+ import com.mentra.bluetoothsdk.sgcs.GlassesAudioSetupPolicy
4
+ import com.mentra.bluetoothsdk.sgcs.GlassesAudioSetupState
5
+
3
6
  data class WifiScanResult(
4
7
  val ssid: String,
5
8
  val requiresPassword: Boolean,
@@ -35,6 +38,8 @@ internal data class GlassesStatus(
35
38
  val voiceActivityDetectionEnabled: Boolean,
36
39
  val connectionState: GlassesConnectionState,
37
40
  val bluetoothClassicConnected: Boolean,
41
+ val bluetoothClassicProfilesConnected: Boolean,
42
+ val bluetoothAudioRouted: Boolean,
38
43
  val signalStrength: Int,
39
44
  val signalStrengthUpdatedAt: Long,
40
45
  val deviceModel: String,
@@ -77,6 +82,14 @@ internal data class GlassesStatus(
77
82
  val controllerBatteryLevel: Int,
78
83
  val controllerSignalStrength: Int,
79
84
  val ringSignalStrength: Int,
85
+ /** Mentra Live `MentraLivePairingState` map (`{state, windowId?, endsAtMs?}`); absent for other glasses. */
86
+ val pairingState: Map<String, Any>? = null,
87
+ /**
88
+ * `GlassesAudioSetupState` map, on every model. Derived from [fullyBooted] by
89
+ * [GlassesAudioSetupPolicy] rather than stored, so a snapshot that reports ready never
90
+ * carries a stale `idle`.
91
+ */
92
+ val audioSetup: Map<String, Any> = GlassesAudioSetupPolicy.state(fullyBooted).toMap(),
80
93
  ) {
81
94
  internal fun toMap(): Map<String, Any> {
82
95
  val values =
@@ -85,6 +98,8 @@ internal data class GlassesStatus(
85
98
  "micEnabled" to micEnabled,
86
99
  "voiceActivityDetectionEnabled" to voiceActivityDetectionEnabled,
87
100
  "bluetoothClassicConnected" to bluetoothClassicConnected,
101
+ "bluetoothClassicProfilesConnected" to bluetoothClassicProfilesConnected,
102
+ "bluetoothAudioRouted" to bluetoothAudioRouted,
88
103
  "signalStrength" to signalStrength,
89
104
  "signalStrengthUpdatedAt" to signalStrengthUpdatedAt,
90
105
  "deviceModel" to deviceModel,
@@ -123,6 +138,8 @@ internal data class GlassesStatus(
123
138
  "ringSignalStrength" to ringSignalStrength,
124
139
  )
125
140
  systemTimeMs?.let { values["systemTimeMs"] = it }
141
+ pairingState?.let { values["pairingState"] = it }
142
+ values["audioSetup"] = audioSetup
126
143
  return values
127
144
  }
128
145
 
@@ -137,6 +154,9 @@ internal data class GlassesStatus(
137
154
  ?: BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED,
138
155
  connectionState = GlassesConnectionState.fromValue(stringValue(values, "connectionState")),
139
156
  bluetoothClassicConnected = boolValue(values, "bluetoothClassicConnected") ?: false,
157
+ // Android has no separate profile/route signal; both mirror the Classic audio state.
158
+ bluetoothClassicProfilesConnected = boolValue(values, "bluetoothClassicConnected") ?: false,
159
+ bluetoothAudioRouted = boolValue(values, "bluetoothClassicConnected") ?: false,
140
160
  signalStrength = numberValue(values, "signalStrength") ?: -1,
141
161
  signalStrengthUpdatedAt = longValue(values, "signalStrengthUpdatedAt") ?: 0L,
142
162
  deviceModel = stringValue(values, "deviceModel") ?: "",
@@ -174,10 +194,25 @@ internal data class GlassesStatus(
174
194
  controllerBatteryLevel = numberValue(values, "controllerBatteryLevel") ?: -1,
175
195
  controllerSignalStrength = numberValue(values, "controllerSignalStrength") ?: -1,
176
196
  ringSignalStrength = numberValue(values, "ringSignalStrength") ?: -1,
197
+ pairingState = pairingStateValue(values),
177
198
  )
178
199
  }
179
200
  }
180
201
 
202
+ /** `audioSetup` from an event or update map, keeping only a known state and its action. */
203
+ internal fun audioSetupValue(values: Map<String, Any>): Map<String, Any>? =
204
+ GlassesAudioSetupState.fromMap(values["audioSetup"])?.toMap()
205
+
206
+ /** `pairingState` from a store or event map, keeping only string keys. */
207
+ internal fun pairingStateValue(values: Map<String, Any>): Map<String, Any>? {
208
+ val raw = values["pairingState"] as? Map<*, *> ?: return null
209
+ val state = raw["state"] as? String ?: return null
210
+ val result = mutableMapOf<String, Any>("state" to state)
211
+ (raw["windowId"] as? Number)?.let { result["windowId"] = it.toInt() }
212
+ (raw["endsAtMs"] as? Number)?.let { result["endsAtMs"] = it.toLong() }
213
+ return result
214
+ }
215
+
181
216
  data class VersionInfoResult(
182
217
  val androidVersion: String,
183
218
  val firmwareVersion: String,
@@ -406,6 +441,8 @@ internal data class GlassesStatusUpdate(
406
441
  val voiceActivityDetectionEnabled: Boolean? = null,
407
442
  val connectionState: GlassesConnectionState? = null,
408
443
  val bluetoothClassicConnected: Boolean? = null,
444
+ val bluetoothClassicProfilesConnected: Boolean? = null,
445
+ val bluetoothAudioRouted: Boolean? = null,
409
446
  val signalStrength: Int? = null,
410
447
  val signalStrengthUpdatedAt: Long? = null,
411
448
  val deviceModel: String? = null,
@@ -442,6 +479,9 @@ internal data class GlassesStatusUpdate(
442
479
  val controllerBatteryLevel: Int? = null,
443
480
  val controllerSignalStrength: Int? = null,
444
481
  val ringSignalStrength: Int? = null,
482
+ val pairingState: Map<String, Any>? = null,
483
+ /** Explicit `GlassesAudioSetupState` map. When absent, an update that sets [fullyBooted] carries the matching state. */
484
+ val audioSetup: Map<String, Any>? = null,
445
485
  ) {
446
486
  internal fun toMap(): Map<String, Any> =
447
487
  buildMap {
@@ -458,6 +498,8 @@ internal data class GlassesStatusUpdate(
458
498
  putIfNotNull("micEnabled", micEnabled)
459
499
  putIfNotNull("voiceActivityDetectionEnabled", voiceActivityDetectionEnabled)
460
500
  putIfNotNull("bluetoothClassicConnected", bluetoothClassicConnected)
501
+ putIfNotNull("bluetoothClassicProfilesConnected", bluetoothClassicProfilesConnected)
502
+ putIfNotNull("bluetoothAudioRouted", bluetoothAudioRouted)
461
503
  putIfNotNull("signalStrength", signalStrength)
462
504
  putIfNotNull("signalStrengthUpdatedAt", signalStrengthUpdatedAt)
463
505
  putIfNotNull("deviceModel", deviceModel)
@@ -498,6 +540,10 @@ internal data class GlassesStatusUpdate(
498
540
  putIfNotNull("controllerBatteryLevel", controllerBatteryLevel)
499
541
  putIfNotNull("controllerSignalStrength", controllerSignalStrength)
500
542
  putIfNotNull("ringSignalStrength", ringSignalStrength)
543
+ pairingState?.let { put("pairingState", it) }
544
+ (audioSetup ?: fullyBooted?.let { GlassesAudioSetupPolicy.state(it).toMap() })?.let {
545
+ put("audioSetup", it)
546
+ }
501
547
  }
502
548
 
503
549
  companion object {
@@ -509,6 +555,8 @@ internal data class GlassesStatusUpdate(
509
555
  voiceActivityDetectionEnabled = optionalBoolValue(values, "voiceActivityDetectionEnabled"),
510
556
  connectionState = GlassesConnectionState.optionalFromValue(optionalStringValue(values, "connectionState")),
511
557
  bluetoothClassicConnected = optionalBoolValue(values, "bluetoothClassicConnected"),
558
+ bluetoothClassicProfilesConnected = optionalBoolValue(values, "bluetoothClassicProfilesConnected"),
559
+ bluetoothAudioRouted = optionalBoolValue(values, "bluetoothAudioRouted"),
512
560
  signalStrength = optionalNumberValue(values, "signalStrength"),
513
561
  signalStrengthUpdatedAt = optionalLongValue(values, "signalStrengthUpdatedAt"),
514
562
  deviceModel = optionalStringValue(values, "deviceModel"),
@@ -559,6 +607,8 @@ internal data class GlassesStatusUpdate(
559
607
  controllerBatteryLevel = optionalNumberValue(values, "controllerBatteryLevel"),
560
608
  controllerSignalStrength = optionalNumberValue(values, "controllerSignalStrength"),
561
609
  ringSignalStrength = optionalNumberValue(values, "ringSignalStrength"),
610
+ pairingState = pairingStateValue(values),
611
+ audioSetup = audioSetupValue(values),
562
612
  )
563
613
  }
564
614
  }