@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.12

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 (118) hide show
  1. package/README.md +56 -9
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +205 -19
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1137 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  28. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  35. package/build/BluetoothSdk.types.d.ts +56 -2
  36. package/build/BluetoothSdk.types.d.ts.map +1 -1
  37. package/build/BluetoothSdk.types.js.map +1 -1
  38. package/build/_internal.d.ts +2 -0
  39. package/build/_internal.d.ts.map +1 -1
  40. package/build/_internal.js +2 -0
  41. package/build/_internal.js.map +1 -1
  42. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  43. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  44. package/build/_private/BluetoothSdkModule.js +1 -1
  45. package/build/_private/BluetoothSdkModule.js.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  47. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  48. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  49. package/build/_private/sdkOtaManifest.d.ts +3 -12
  50. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  51. package/build/_private/sdkOtaManifest.js +5 -17
  52. package/build/_private/sdkOtaManifest.js.map +1 -1
  53. package/build/generated/releaseMetadata.d.ts +12 -0
  54. package/build/generated/releaseMetadata.d.ts.map +1 -0
  55. package/build/generated/releaseMetadata.js +10 -0
  56. package/build/generated/releaseMetadata.js.map +1 -0
  57. package/build/index.d.ts +1 -1
  58. package/build/index.d.ts.map +1 -1
  59. package/build/index.js +10 -2
  60. package/build/index.js.map +1 -1
  61. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  62. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  63. package/build/ota-server/MentraOtaServer.types.js +2 -0
  64. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  66. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.js +3 -0
  68. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  70. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  72. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  73. package/build/ota-server/index.d.ts +3 -0
  74. package/build/ota-server/index.d.ts.map +1 -0
  75. package/build/ota-server/index.js +3 -0
  76. package/build/ota-server/index.js.map +1 -0
  77. package/expo-module.config.json +3 -2
  78. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  79. package/ios/BluetoothSdkModule.swift +16 -2
  80. package/ios/LocalIPv4.swift +150 -0
  81. package/ios/LocalOtaServer.swift +309 -0
  82. package/ios/MentraBluetoothSDK.podspec +5 -1
  83. package/ios/MentraOtaServerModule.swift +147 -0
  84. package/ios/MentraPhotoReceiverModule.swift +1 -81
  85. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  86. package/ios/Source/Bridge.swift +43 -4
  87. package/ios/Source/DeviceManager.swift +143 -13
  88. package/ios/Source/DeviceStore.swift +2 -0
  89. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  90. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  91. package/ios/Source/OtaManifest.swift +5 -7
  92. package/ios/Source/audio/AudioModels.swift +17 -0
  93. package/ios/Source/camera/CameraModels.swift +1 -0
  94. package/ios/Source/events/BluetoothEvents.swift +27 -0
  95. package/ios/Source/sgcs/MentraLive.swift +410 -30
  96. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  97. package/ios/Source/sgcs/SGCManager.swift +11 -0
  98. package/ios/Source/status/DeviceStatus.swift +23 -0
  99. package/ios/Source/types/DeviceModels.swift +23 -2
  100. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  101. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
  102. package/package.json +5 -1
  103. package/plugin/build/withIos.d.ts +3 -1
  104. package/plugin/build/withIos.js +22 -10
  105. package/scripts/verify-release-package.mjs +34 -0
  106. package/scripts/write-release-metadata.mjs +154 -0
  107. package/scripts/write-release-metadata.test.mjs +29 -0
  108. package/src/BluetoothSdk.types.ts +67 -3
  109. package/src/_internal.ts +2 -0
  110. package/src/_private/BluetoothSdkModule.ts +5 -5
  111. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  112. package/src/_private/sdkOtaManifest.ts +5 -17
  113. package/src/generated/releaseMetadata.ts +20 -0
  114. package/src/index.ts +19 -2
  115. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  116. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  117. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  118. package/src/ota-server/index.ts +2 -0
@@ -10,6 +10,7 @@ import android.bluetooth.BluetoothGatt
10
10
  import android.bluetooth.BluetoothGattCallback
11
11
  import android.bluetooth.BluetoothGattCharacteristic
12
12
  import android.bluetooth.BluetoothGattDescriptor
13
+ import android.bluetooth.BluetoothHeadset
13
14
  import android.bluetooth.BluetoothManager
14
15
  import android.bluetooth.BluetoothProfile
15
16
  import android.bluetooth.le.BluetoothLeScanner
@@ -76,6 +77,11 @@ import org.json.JSONArray
76
77
  import org.json.JSONException
77
78
  import org.json.JSONObject
78
79
 
80
+ internal fun hasRequiredMentraLiveCoreCharacteristics(
81
+ hasRxCharacteristic: Boolean,
82
+ hasTxCharacteristic: Boolean,
83
+ ): Boolean = hasRxCharacteristic && hasTxCharacteristic
84
+
79
85
  /**
80
86
  * Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
81
87
  * glasses
@@ -83,6 +89,12 @@ import org.json.JSONObject
83
89
  * Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
84
90
  * display-related methods are stubbed out and will log a message but not actually display anything.
85
91
  */
92
+ private fun jsonNullableString(json: org.json.JSONObject, key: String): String? {
93
+ if (!json.has(key) || json.isNull(key)) return null
94
+ val value = json.optString(key, "")
95
+ return value.takeIf { it.isNotEmpty() }
96
+ }
97
+
86
98
  class MentraLive : SGCManager() {
87
99
  private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
88
100
 
@@ -99,6 +111,9 @@ class MentraLive : SGCManager() {
99
111
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
100
112
  private const val LOUDNESS_GATE_SWITCH_TYPE = 10
101
113
  private const val BES2700_MTU_LIMIT = 509
114
+ private const val A2DP_CONNECT_MAX_ATTEMPTS = 5
115
+ private const val A2DP_CONNECT_RETRY_MS = 800L
116
+ private const val UNPAIR_FLUSH_MS = 400L
102
117
 
103
118
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
104
119
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
@@ -142,6 +157,253 @@ class MentraLive : SGCManager() {
142
157
  10000 // 10 seconds for reconnection scans (faster than 60s default)
143
158
  private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
144
159
  10_000L // 10s before first scan after shutdown
160
+
161
+ private const val MENTRA_MANUFACTURER_ID = 0xB822
162
+
163
+ private fun manufacturerData(result: ScanResult): ByteArray? {
164
+ return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
165
+ }
166
+
167
+ private fun isPairingDiscoverable(result: ScanResult): Boolean {
168
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result))?.pairingMode ==
169
+ true
170
+ }
171
+
172
+ private fun advertisesPairingFlag(result: ScanResult): Boolean {
173
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result)) != null
174
+ }
175
+
176
+ private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
177
+ val advertisement =
178
+ MentraLivePairingAdvertisementParser.parse(manufacturerData(result))
179
+ ?: return Triple(false, null, false)
180
+ return Triple(advertisement.pairingMode, advertisement.pairingCode, true)
181
+ }
182
+
183
+ fun isMentraLiveBluetoothName(name: String?): Boolean {
184
+ if (name.isNullOrEmpty()) {
185
+ return false
186
+ }
187
+ return name == "Xy_A" ||
188
+ name.startsWith("XyBLE_") ||
189
+ name.startsWith("MENTRA_LIVE_BLE") ||
190
+ name.startsWith("MENTRA_LIVE_BT") ||
191
+ name.lowercase().startsWith("mentra_live")
192
+ }
193
+
194
+ private fun safeBondedDeviceName(device: BluetoothDevice): String {
195
+ return try {
196
+ device.name ?: ""
197
+ } catch (_: SecurityException) {
198
+ ""
199
+ } catch (_: Exception) {
200
+ ""
201
+ }
202
+ }
203
+
204
+ private fun removeBondStandalone(device: BluetoothDevice): Boolean {
205
+ return try {
206
+ val method = device.javaClass.getMethod("removeBond")
207
+ val result = method.invoke(device) as Boolean
208
+ Bridge.log(
209
+ "LIVE: CTKD: removeBond " +
210
+ safeBondedDeviceName(device) +
211
+ " (" +
212
+ device.address +
213
+ ") result=" +
214
+ result
215
+ )
216
+ result
217
+ } catch (e: Exception) {
218
+ Bridge.log("LIVE: CTKD: removeBond error: " + e.message)
219
+ false
220
+ }
221
+ }
222
+
223
+ private fun invokeHiddenProfileDisconnectStandalone(
224
+ proxy: BluetoothProfile,
225
+ device: BluetoothDevice,
226
+ proxyClass: Class<*>,
227
+ label: String
228
+ ) {
229
+ try {
230
+ val state = proxy.getConnectionState(device)
231
+ if (state == BluetoothProfile.STATE_CONNECTED ||
232
+ state == BluetoothProfile.STATE_CONNECTING
233
+ ) {
234
+ val disconnectMethod =
235
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
236
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
237
+ Bridge.log(
238
+ "LIVE: Classic: " +
239
+ label +
240
+ " disconnect " +
241
+ device.address +
242
+ " result=" +
243
+ result
244
+ )
245
+ }
246
+ } catch (e: Exception) {
247
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
248
+ }
249
+ }
250
+
251
+ private fun requestProfileDisconnectStandalone(
252
+ context: Context,
253
+ adapter: BluetoothAdapter,
254
+ profile: Int,
255
+ device: BluetoothDevice,
256
+ proxyClass: Class<*>,
257
+ label: String
258
+ ) {
259
+ try {
260
+ adapter.getProfileProxy(
261
+ context,
262
+ object : BluetoothProfile.ServiceListener {
263
+ override fun onServiceConnected(
264
+ profileType: Int,
265
+ proxy: BluetoothProfile
266
+ ) {
267
+ if (profileType != profile) {
268
+ return
269
+ }
270
+ try {
271
+ invokeHiddenProfileDisconnectStandalone(
272
+ proxy,
273
+ device,
274
+ proxyClass,
275
+ label
276
+ )
277
+ } finally {
278
+ try {
279
+ adapter.closeProfileProxy(profile, proxy)
280
+ } catch (_: Exception) {
281
+ }
282
+ }
283
+ }
284
+
285
+ override fun onServiceDisconnected(profileType: Int) {}
286
+ },
287
+ profile
288
+ )
289
+ } catch (e: Exception) {
290
+ Bridge.log("LIVE: Classic: request " + label + " proxy failed: " + e.message)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Drop A2DP/HFP + removeBond for every bonded Mentra Live unit. Safe when no SGC
296
+ * instance exists — [DeviceManager.forget] often runs after disconnect() nulled sgc
297
+ * (abandonAttempt), which previously skipped MentraLive.forget() entirely and left
298
+ * Classic ACL up so glasses never re-advertised.
299
+ */
300
+ @JvmStatic
301
+ @JvmOverloads
302
+ fun unbondBondedMentraLiveDevices(context: Context?, extraAddress: String? = null) {
303
+ if (context == null) {
304
+ Bridge.log("LIVE: CTKD: unbondBondedMentraLiveDevices — no context")
305
+ return
306
+ }
307
+ val adapter =
308
+ try {
309
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
310
+ ?.adapter
311
+ } catch (e: Exception) {
312
+ Bridge.log("LIVE: CTKD: unbond — adapter error: " + e.message)
313
+ null
314
+ }
315
+ if (adapter == null) {
316
+ Bridge.log("LIVE: CTKD: unbond — BluetoothAdapter unavailable")
317
+ return
318
+ }
319
+ val bonded =
320
+ try {
321
+ adapter.bondedDevices?.toList().orEmpty()
322
+ } catch (e: Exception) {
323
+ Bridge.log("LIVE: CTKD: unbond — cannot list bonded: " + e.message)
324
+ emptyList()
325
+ }
326
+ val extra = extraAddress?.uppercase()
327
+ val targets =
328
+ bonded.filter {
329
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
330
+ (extra != null && it.address.equals(extra, ignoreCase = true))
331
+ }
332
+ if (targets.isEmpty()) {
333
+ Bridge.log("LIVE: CTKD: unbond — no bonded Mentra Live devices")
334
+ return
335
+ }
336
+ for (device in targets) {
337
+ Bridge.log(
338
+ "LIVE: CTKD: tearing down bonded Mentra Live " +
339
+ safeBondedDeviceName(device) +
340
+ " (" +
341
+ device.address +
342
+ ")"
343
+ )
344
+ requestProfileDisconnectStandalone(
345
+ context,
346
+ adapter,
347
+ BluetoothProfile.A2DP,
348
+ device,
349
+ BluetoothA2dp::class.java,
350
+ "A2DP"
351
+ )
352
+ requestProfileDisconnectStandalone(
353
+ context,
354
+ adapter,
355
+ BluetoothProfile.HEADSET,
356
+ device,
357
+ BluetoothHeadset::class.java,
358
+ "HFP"
359
+ )
360
+ removeBondStandalone(device)
361
+ }
362
+ Handler(Looper.getMainLooper())
363
+ .postDelayed({ retryUnbondIfStillPaired(context, extraAddress) }, 700)
364
+ }
365
+
366
+ private fun retryUnbondIfStillPaired(context: Context?, extraAddress: String?) {
367
+ if (context == null) {
368
+ return
369
+ }
370
+ val adapter =
371
+ try {
372
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
373
+ ?.adapter
374
+ } catch (_: Exception) {
375
+ null
376
+ }
377
+ ?: return
378
+ val extra = extraAddress?.uppercase()
379
+ val leftover =
380
+ try {
381
+ adapter.bondedDevices?.toList().orEmpty()
382
+ } catch (_: Exception) {
383
+ emptyList()
384
+ }
385
+ .filter {
386
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
387
+ (extra != null &&
388
+ it.address.equals(extra, ignoreCase = true))
389
+ }
390
+ if (leftover.isEmpty()) {
391
+ Bridge.log("LIVE: CTKD: unbond verify — no Mentra Live bonds remain")
392
+ return
393
+ }
394
+ for (device in leftover) {
395
+ Bridge.log(
396
+ "LIVE: CTKD: unbond retry still paired " +
397
+ safeBondedDeviceName(device) +
398
+ " (" +
399
+ device.address +
400
+ ") bondState=" +
401
+ device.bondState
402
+ )
403
+ removeBondStandalone(device)
404
+ }
405
+ }
406
+
145
407
  private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
146
408
 
147
409
  // Keep-alive parameters
@@ -253,6 +515,16 @@ class MentraLive : SGCManager() {
253
515
 
254
516
  private var reconnectAttempts = 0
255
517
  private var isReconnecting = false // Track if we're in reconnection mode
518
+ // Manual discovery/connect invalidates delayed reconnect work from an earlier GATT failure.
519
+ private var reconnectGeneration = 0
520
+ // Pair Glasses discovery is an explicit no-reconnect zone. It ends only when
521
+ // the user selects a scan result (connectById) or the manager is torn down.
522
+ private var manualDiscoveryActive = false
523
+ // Set by connectById() so a post-disconnect name scan actually GATT-connects.
524
+ // Discovery-only scans (findCompatibleDevices) leave this false so a leftover
525
+ // savedDeviceName cannot auto-connect a spoofed advertiser (iOS parity: iOS
526
+ // connects on saved-name match because connectById writes that name first).
527
+ private var explicitConnectByName = false
256
528
  /**
257
529
  * Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
258
530
  * so glasses can reboot.
@@ -276,18 +548,35 @@ class MentraLive : SGCManager() {
276
548
  private val fileProcessingHandler = Handler(fileProcessingThread.looper)
277
549
  private var scheduler: ScheduledExecutorService? = null
278
550
  private var isScanning = false
551
+ // Prevent an old scan timeout from stopping a newer manual scan.
552
+ private var scanGeneration = 0
279
553
  private var isConnecting = false
280
554
  private var isKilled = false
281
555
 
556
+ /** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
557
+ private var pairingYieldActive = false
558
+ private var pairingYieldEndRunnable: Runnable? = null
559
+ /** After yield ends, auth/not-owner failures may clear saved glasses. */
560
+ private var pairingYieldAwaitingReclaim = false
561
+ /** Hold GATT teardown until the unpair write can leave the phone. */
562
+ private var unpairFlushPending = false
563
+ private var unpairFlushRunnable: Runnable? = null
564
+
282
565
  // CTKD (Cross-Transport Key Derivation) support for BES devices
283
566
  private var isBondingReceiverRegistered = false
284
567
  private var isBtClassicConnected = false
285
568
  private var bondingReceiver: BroadcastReceiver? = null
286
569
  private var bondingRetryCount = 0
287
570
 
571
+ // Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
572
+ private var pairingTimingActive = false
573
+ private var pairingTimingStartMs = 0L
574
+ private var pairingTimingLastMs = 0L
575
+
288
576
  // A2DP profile connection for already-bonded devices
289
577
  private var a2dpProfile: BluetoothA2dp? = null
290
578
  private var isA2dpProxyRegistered = false
579
+ private var a2dpConnectAttempts = 0
291
580
 
292
581
  private data class OutgoingBleCommandTraceInfo(
293
582
  val commandType: String,
@@ -609,6 +898,9 @@ class MentraLive : SGCManager() {
609
898
  // Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
610
899
  private var glassesReadyReceived = false
611
900
  private var audioConnected = false
901
+ // CTKD/A2DP must wait until GATT notifications are armed; starting Classic while the
902
+ // BLE link is still discovering services commonly yields GATT status 19 on Samsung.
903
+ private var ctkdInitiatedThisGattSession = false
612
904
 
613
905
  // Micbeat tracking - periodically enable custom audio TX
614
906
  private var micBeatHandler = Handler(Looper.getMainLooper())
@@ -624,7 +916,6 @@ class MentraLive : SGCManager() {
624
916
  private val secureRandom = SecureRandom()
625
917
  private val deviceId = System.currentTimeMillis() xor Random().nextLong()
626
918
 
627
- private var lastReceivedLc3Sequence: Byte = -1
628
919
  private var lc3SequenceNumber: Byte = 0
629
920
  private var lc3DecoderPtr = 0L
630
921
  private var lc3AudioPlayer: Lc3Player? = null
@@ -811,6 +1102,9 @@ class MentraLive : SGCManager() {
811
1102
  }
812
1103
 
813
1104
  private fun updateConnectionState(state: String) {
1105
+ if (state == ConnTypes.DISCONNECTED) {
1106
+ DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
1107
+ }
814
1108
  val isEqual = state == connectionState
815
1109
  if (isEqual) {
816
1110
  if (state == ConnTypes.DISCONNECTED) {
@@ -833,6 +1127,7 @@ class MentraLive : SGCManager() {
833
1127
  // same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
834
1128
  DeviceStore.apply("glasses", "serialNumber", "")
835
1129
  DeviceStore.apply("glasses", "bluetoothMacAddress", "")
1130
+ DeviceStore.apply("glasses", "wifiMacAddress", "")
836
1131
  }
837
1132
 
838
1133
  // Actually update the connection state!
@@ -905,6 +1200,10 @@ class MentraLive : SGCManager() {
905
1200
 
906
1201
  /** Starts BLE scanning for Mentra Live glasses */
907
1202
  private fun startScan() {
1203
+ if (pairingYieldActive) {
1204
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
1205
+ return
1206
+ }
908
1207
  if (bluetoothAdapter == null || isScanning) {
909
1208
  return
910
1209
  }
@@ -963,13 +1262,22 @@ class MentraLive : SGCManager() {
963
1262
  )
964
1263
  }
965
1264
 
1265
+ val generation = ++scanGeneration
966
1266
  isScanning = true
967
1267
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
1268
+ // Connected glasses stop advertising (BLE_CONNECTION_MAX=1). Pairing scan
1269
+ // would otherwise return empty even though GATT is already up.
1270
+ if (!isReconnecting) {
1271
+ handler.post { emitConnectedDeviceForPairingScan() }
1272
+ }
968
1273
 
969
1274
  // Set a timeout to stop scanning
970
1275
  handler.postDelayed(
971
1276
  object : Runnable {
972
1277
  override fun run() {
1278
+ if (generation != scanGeneration) {
1279
+ return
1280
+ }
973
1281
  if (isScanning) {
974
1282
  stopScan()
975
1283
  emitStopScanEvent()
@@ -1013,11 +1321,13 @@ class MentraLive : SGCManager() {
1013
1321
 
1014
1322
  /** Stops BLE scanning */
1015
1323
  override fun stopScan() {
1324
+ manualDiscoveryActive = false
1016
1325
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
1017
1326
  return
1018
1327
  }
1019
1328
 
1020
1329
  try {
1330
+ scanGeneration++
1021
1331
  bluetoothScanner!!.stopScan(scanCallback)
1022
1332
  isScanning = false
1023
1333
  DeviceStore.apply("bluetooth", "searching", false)
@@ -1041,6 +1351,33 @@ class MentraLive : SGCManager() {
1041
1351
  Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
1042
1352
  }
1043
1353
 
1354
+ /**
1355
+ * Pairing scan listens for advertisements. A unit that is already GATT-connected
1356
+ * (typical after a CTKD retry / back-out on field firmware) has stopped ADV, so
1357
+ * emit it as pairable or the scan list stays empty.
1358
+ */
1359
+ private fun emitConnectedDeviceForPairingScan() {
1360
+ if (!isConnected || isReconnecting || pairingYieldActive) {
1361
+ return
1362
+ }
1363
+ val device = connectedDevice ?: bluetoothGatt?.device ?: return
1364
+ val name = safeBondedDeviceName(device)
1365
+ if (!isMentraLiveBluetoothName(name)) {
1366
+ return
1367
+ }
1368
+ Bridge.log(
1369
+ "LIVE: Pairing scan: already GATT-connected to $name — emitting as pairable (ADV off while connected)"
1370
+ )
1371
+ Bridge.sendDiscoveredDevice(
1372
+ DeviceTypes.LIVE,
1373
+ name,
1374
+ device.address ?: "",
1375
+ pairingMode = true,
1376
+ pairingCode = null,
1377
+ securePairingCapable = false,
1378
+ )
1379
+ }
1380
+
1044
1381
  var seenDevices: MutableSet<String> = HashSet()
1045
1382
 
1046
1383
  /** BLE Scan callback */
@@ -1093,42 +1430,77 @@ class MentraLive : SGCManager() {
1093
1430
  deviceName.startsWith("MENTRA_LIVE_BT") ||
1094
1431
  deviceName.lowercase().startsWith("mentra_live")
1095
1432
  ) {
1433
+ // Connect-by-name only when reconnecting OR connectById() asked for
1434
+ // this name. A discovery-only pairing scan must not auto-connect just
1435
+ // because savedDeviceName still matches (spoof risk).
1436
+ val isNamedConnectTarget =
1437
+ savedDeviceName.isNotEmpty() &&
1438
+ savedDeviceName == deviceName &&
1439
+ (isReconnecting || explicitConnectByName)
1440
+ if (!isReconnecting &&
1441
+ advertisesPairingFlag(result) &&
1442
+ !isPairingDiscoverable(result)
1443
+ ) {
1444
+ // Tell RN a nearby unit is advertising but not pairable so the
1445
+ // empty-state hint can fire. The scan list never shows these.
1446
+ Bridge.log(
1447
+ "LIVE: Nearby $deviceName is secure firmware not in pairing mode — hiding from scan list"
1448
+ )
1449
+ Bridge.sendDiscoveredDevice(
1450
+ DeviceTypes.LIVE,
1451
+ deviceName,
1452
+ deviceAddress,
1453
+ result.rssi,
1454
+ pairingMode = false,
1455
+ pairingCode = null,
1456
+ securePairingCapable = true,
1457
+ )
1458
+ return
1459
+ }
1096
1460
  val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
1461
+ val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
1097
1462
  Bridge.log(
1098
1463
  "LIVE: Found compatible " +
1099
1464
  glassType +
1100
1465
  " glasses device: " +
1101
- deviceName
1466
+ deviceName +
1467
+ if (secureCapable) " (pairingMode=$pairingMode)"
1468
+ else " (legacy firmware, pairable)"
1102
1469
  )
1103
- // EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
1104
- // smartGlassesDevice.deviceModelName, deviceName));
1105
1470
  Bridge.sendDiscoveredDevice(
1106
1471
  DeviceTypes.LIVE,
1107
1472
  deviceName,
1108
1473
  deviceAddress,
1109
- result.rssi
1474
+ result.rssi,
1475
+ // Field firmware has no pairing flag. Treat it as pairable so the
1476
+ // new Mentra App can still find old glasses.
1477
+ pairingMode = if (secureCapable) pairingMode else true,
1478
+ pairingCode = pairingCode,
1479
+ securePairingCapable = secureCapable,
1110
1480
  )
1111
1481
 
1112
- // If this is the specific device we want to connect to by name, connect to
1113
- // it
1114
- if (savedDeviceName != null && savedDeviceName == deviceName) {
1482
+ // connectById / reconnect: user (or reconnect) asked for this name
1483
+ // GATT-connect. Discovery-only scans never set explicitConnectByName.
1484
+ if (isNamedConnectTarget) {
1115
1485
  Log.i(
1116
1486
  TAG,
1117
- "🔌 🎯 RECONNECT TARGET FOUND - Device: " +
1487
+ "🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
1118
1488
  deviceName +
1119
1489
  " (Attempt #" +
1120
1490
  reconnectAttempts +
1121
1491
  ")"
1122
1492
  )
1123
1493
  Bridge.log(
1124
- "LIVE: 🔌 🎯 Found our remembered device by name, connecting: " +
1494
+ "LIVE: 🔌 🎯 Found target device by name, connecting: " +
1125
1495
  deviceName +
1126
- " (Reconnect attempt #" +
1127
- reconnectAttempts +
1128
- ")"
1496
+ (if (isReconnecting)
1497
+ " (Reconnect attempt #" +
1498
+ reconnectAttempts +
1499
+ ")"
1500
+ else " (connectById)")
1129
1501
  )
1130
1502
  synchronized(connectionLock) {
1131
- if (isConnected || isConnecting) {
1503
+ if (pairingYieldActive || isConnected || isConnecting) {
1132
1504
  return
1133
1505
  }
1134
1506
  isConnecting = true
@@ -1136,6 +1508,7 @@ class MentraLive : SGCManager() {
1136
1508
  stopScan()
1137
1509
  emitStopScanEvent()
1138
1510
  isReconnecting = false
1511
+ explicitConnectByName = false
1139
1512
  connectToDevice(device)
1140
1513
  }
1141
1514
  }
@@ -1217,11 +1590,47 @@ class MentraLive : SGCManager() {
1217
1590
  return false
1218
1591
  }
1219
1592
 
1593
+ private fun beginPairingTiming(reason: String) {
1594
+ pairingTimingStartMs = SystemClock.elapsedRealtime()
1595
+ pairingTimingLastMs = pairingTimingStartMs
1596
+ pairingTimingActive = true
1597
+ Bridge.log("LIVE: PAIRING_TIMING begin reason=$reason sinceStartMs=0 deltaMs=0")
1598
+ }
1599
+
1600
+ private fun markPairingTiming(checkpoint: String, detail: String = "") {
1601
+ if (!pairingTimingActive) {
1602
+ return
1603
+ }
1604
+ val now = SystemClock.elapsedRealtime()
1605
+ val sinceStart = now - pairingTimingStartMs
1606
+ val delta = now - pairingTimingLastMs
1607
+ pairingTimingLastMs = now
1608
+ val extra = if (detail.isEmpty()) "" else " $detail"
1609
+ Bridge.log(
1610
+ "LIVE: PAIRING_TIMING checkpoint=$checkpoint sinceStartMs=$sinceStart deltaMs=$delta$extra"
1611
+ )
1612
+ }
1613
+
1614
+ private fun endPairingTiming(checkpoint: String, detail: String = "") {
1615
+ markPairingTiming(checkpoint, detail)
1616
+ pairingTimingActive = false
1617
+ }
1618
+
1220
1619
  /** Connect to a specific BLE device */
1221
1620
  private fun connectToDevice(device: BluetoothDevice?) {
1222
1621
  if (device == null) {
1223
1622
  return
1224
1623
  }
1624
+ if (pairingYieldActive) {
1625
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1626
+ isConnecting = false
1627
+ isReconnecting = false
1628
+ return
1629
+ }
1630
+
1631
+ beginPairingTiming(
1632
+ "connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
1633
+ )
1225
1634
 
1226
1635
  // Cancel any previous connection timeouts
1227
1636
  if (connectionTimeoutRunnable != null) {
@@ -1321,6 +1730,85 @@ class MentraLive : SGCManager() {
1321
1730
  // }
1322
1731
  // }
1323
1732
 
1733
+ private fun enterPairingYield(windowMs: Long) {
1734
+ pairingYieldActive = true
1735
+ pairingYieldAwaitingReclaim = false
1736
+ isReconnecting = false
1737
+ isConnecting = false
1738
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1739
+ if (connectionTimeoutRunnable != null) {
1740
+ connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
1741
+ connectionTimeoutRunnable = null
1742
+ }
1743
+ // Stop scans/reconnect work; keep default_wearable / Classic bond.
1744
+ if (isScanning) {
1745
+ stopScan()
1746
+ emitStopScanEvent()
1747
+ }
1748
+ // Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
1749
+ // Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
1750
+ val yieldingDevice = connectedDevice
1751
+ disconnectClassicProfiles(yieldingDevice)
1752
+ // closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
1753
+ // onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
1754
+ // Mirror a real disconnect for UI + session state, then tear GATT down.
1755
+ isConnected = false
1756
+ connectedDevice = null
1757
+ glassesReady = false
1758
+ glassesSessionId = null
1759
+ readinessCompletedThisBleSession = false
1760
+ glassesReadyReceived = false
1761
+ ctkdInitiatedThisGattSession = false
1762
+ audioConnected = false
1763
+ notificationsEnabled = false
1764
+ processSendQueueRunnable?.let { handler.removeCallbacks(it) }
1765
+ stopReadinessCheckLoop()
1766
+ stopHeartbeat()
1767
+ stopSignalStrengthPolling()
1768
+ stopMicBeat()
1769
+ closeL2capFileChannel()
1770
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1771
+ clearFilePacketBuffer()
1772
+ updateConnectionState(ConnTypes.DISCONNECTED)
1773
+ closeGattQuietly(true)
1774
+ // Stand down for the whole window. Probing GATT during yield races the new
1775
+ // phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
1776
+ val end =
1777
+ Runnable {
1778
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1779
+ pairingYieldActive = false
1780
+ pairingYieldAwaitingReclaim = true
1781
+ pairingYieldEndRunnable = null
1782
+ if (!isKilled && !isConnected && !isConnecting) {
1783
+ handleReconnection()
1784
+ }
1785
+ }
1786
+ pairingYieldEndRunnable = end
1787
+ handler.postDelayed(end, windowMs)
1788
+ }
1789
+
1790
+ fun isPairingYieldActive(): Boolean = pairingYieldActive
1791
+
1792
+ private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
1793
+ Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
1794
+ pairingYieldAwaitingReclaim = false
1795
+ pairingYieldActive = false
1796
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1797
+ pairingYieldEndRunnable = null
1798
+ try {
1799
+ forget()
1800
+ } catch (e: Exception) {
1801
+ Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
1802
+ }
1803
+ DeviceStore.apply("bluetooth", "default_wearable", "")
1804
+ DeviceStore.apply("bluetooth", "device_name", "")
1805
+ DeviceStore.apply("bluetooth", "device_address", "")
1806
+ Bridge.saveSetting("default_wearable", "")
1807
+ Bridge.saveSetting("device_name", "")
1808
+ Bridge.saveSetting("device_address", "")
1809
+ Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
1810
+ }
1811
+
1324
1812
  /** Handle reconnection with exponential backoff */
1325
1813
  private fun handleReconnection() {
1326
1814
  // Don't attempt reconnection if we've been killed/forgotten
@@ -1330,6 +1818,20 @@ class MentraLive : SGCManager() {
1330
1818
  return
1331
1819
  }
1332
1820
 
1821
+ if (manualDiscoveryActive) {
1822
+ Bridge.log(
1823
+ "LIVE: 🔌 RECONNECT ABORTED - Pair Glasses discovery is active"
1824
+ )
1825
+ isReconnecting = false
1826
+ return
1827
+ }
1828
+
1829
+ if (pairingYieldActive) {
1830
+ Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
1831
+ isReconnecting = false
1832
+ return
1833
+ }
1834
+
1333
1835
  if (isReconnecting) {
1334
1836
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
1335
1837
  return
@@ -1401,10 +1903,25 @@ class MentraLive : SGCManager() {
1401
1903
  "ms)"
1402
1904
  )
1403
1905
 
1404
- // Schedule reconnection attempt
1906
+ // Schedule reconnection attempt. A later manual scan/connect invalidates this
1907
+ // callback so stale saved identity cannot hijack user-initiated discovery.
1908
+ val generation = reconnectGeneration
1405
1909
  handler.postDelayed(
1406
1910
  object : Runnable {
1407
1911
  override fun run() {
1912
+ if (generation != reconnectGeneration) {
1913
+ Bridge.log(
1914
+ "LIVE: 🔌 RECONNECT ABORTED - superseded by manual discovery"
1915
+ )
1916
+ return
1917
+ }
1918
+ if (pairingYieldActive) {
1919
+ isReconnecting = false
1920
+ Bridge.log(
1921
+ "LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
1922
+ )
1923
+ return
1924
+ }
1408
1925
  if (!isConnected && !isConnecting && !isKilled) {
1409
1926
  // Prefer saved MAC for direct GATT connect (faster and more reliable
1410
1927
  // than scanning).
@@ -1554,6 +2071,7 @@ class MentraLive : SGCManager() {
1554
2071
  Bridge.log(
1555
2072
  "LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
1556
2073
  )
2074
+ markPairingTiming("gatt_connected")
1557
2075
  isConnecting = false
1558
2076
  isConnected = true
1559
2077
  connectedDevice = gatt.device
@@ -1581,42 +2099,27 @@ class MentraLive : SGCManager() {
1581
2099
  // connectedDevice.getName());
1582
2100
  // }
1583
2101
 
1584
- // CTKD Implementation: Register bonding receiver and create bond for BT
1585
- // Classic
2102
+ // Register early so we don't miss bond broadcasts, but defer
2103
+ // createBond / A2DP until BLE GATT setup finishes (see
2104
+ // maybeInitiateCtkdAudioAfterBleReady).
1586
2105
  registerBondingReceiver()
1587
- bondingRetryCount = 0 // Reset retry counter for new connection
2106
+ bondingRetryCount = 0
2107
+ ctkdInitiatedThisGattSession = false
1588
2108
  Bridge.log(
1589
- "LIVE: CTKD: BLE connection established, initiating CTKD bonding for BT Classic"
2109
+ "LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
2110
+ )
2111
+ markPairingTiming(
2112
+ "ctkd_deferred",
2113
+ "bondState=${connectedDevice!!.bondState}"
1590
2114
  )
1591
-
1592
- // Check if device is already bonded before attempting to create bond
1593
- if (connectedDevice!!.bondState == BluetoothDevice.BOND_BONDED) {
1594
- Bridge.log(
1595
- "LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
1596
- )
1597
- // Device is bonded but we need to explicitly connect the A2DP audio
1598
- // profile
1599
- // Just being bonded doesn't mean the audio profile is connected
1600
- connectA2dpProfile(connectedDevice!!)
1601
- // Note: audioConnected will be set to true once A2DP profile
1602
- // connects
1603
- } else {
1604
- createBond(connectedDevice!!)
1605
- }
1606
2115
 
1607
2116
  // Discover services
1608
2117
  gatt.discoverServices()
1609
2118
 
1610
- // Reset reconnect attempts on successful connection
1611
- val previousAttempts = reconnectAttempts
1612
- reconnectAttempts = 0
1613
- isReconnecting = false // Clear reconnection mode
1614
- Log.i(
1615
- TAG,
1616
- "🔌 ✅ Reconnection counter reset (was at " +
1617
- previousAttempts +
1618
- " attempts)"
1619
- )
2119
+ // Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
2120
+ // followed by status 19 was zeroing the counter every ~1s and
2121
+ // preventing exponential backoff. Reset at ble_chars_ready instead.
2122
+ isReconnecting = false
1620
2123
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1621
2124
  Log.w(
1622
2125
  TAG,
@@ -1625,6 +2128,10 @@ class MentraLive : SGCManager() {
1625
2128
  Bridge.log(
1626
2129
  "LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
1627
2130
  )
2131
+ endPairingTiming(
2132
+ "gatt_disconnected",
2133
+ "queueSize=${sendQueue.size}"
2134
+ )
1628
2135
  isConnected = false
1629
2136
  isConnecting = false
1630
2137
 
@@ -1635,6 +2142,7 @@ class MentraLive : SGCManager() {
1635
2142
 
1636
2143
  // Reset audio pairing flags
1637
2144
  glassesReadyReceived = false
2145
+ ctkdInitiatedThisGattSession = false
1638
2146
  audioConnected = false
1639
2147
 
1640
2148
  notificationsEnabled = false
@@ -1693,6 +2201,10 @@ class MentraLive : SGCManager() {
1693
2201
  status +
1694
2202
  ") - Will retry reconnection"
1695
2203
  )
2204
+ endPairingTiming(
2205
+ "gatt_error",
2206
+ "status=$status queueSize=${sendQueue.size}"
2207
+ )
1696
2208
  isConnected = false
1697
2209
  isConnecting = false
1698
2210
  glassesReady = false
@@ -1700,6 +2212,7 @@ class MentraLive : SGCManager() {
1700
2212
  readinessCompletedThisBleSession = false
1701
2213
  glassesReadyReceived = false
1702
2214
  audioConnected = false
2215
+ ctkdInitiatedThisGattSession = false
1703
2216
 
1704
2217
  notificationsEnabled = false
1705
2218
 
@@ -1723,6 +2236,19 @@ class MentraLive : SGCManager() {
1723
2236
  // Clean up resources
1724
2237
  closeGattQuietly(false)
1725
2238
 
2239
+ // Auth/not-owner after pairing yield — not a generic RF blip.
2240
+ // GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
2241
+ // not wipe the saved owner.
2242
+ if (pairingYieldAwaitingReclaim &&
2243
+ (status == 5 ||
2244
+ status == 15 ||
2245
+ status == 0x05 ||
2246
+ status == 0x0F)
2247
+ ) {
2248
+ clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
2249
+ return
2250
+ }
2251
+
1726
2252
  // Attempt reconnection if not killed
1727
2253
  if (!isKilled) {
1728
2254
  Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
@@ -1773,24 +2299,47 @@ class MentraLive : SGCManager() {
1773
2299
  txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
1774
2300
  rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
1775
2301
 
1776
- // Get LC3 characteristics (always supported)
2302
+ // LC3 audio is optional. Older firmware can still run the core session
2303
+ // and receive an OTA without these characteristics.
1777
2304
  lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
1778
2305
  lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
1779
2306
 
1780
- // Check if we have required characteristics
2307
+ // Match iOS: only the core transport pair is required for readiness.
1781
2308
  val hasRequiredCharacteristics =
1782
- (rxCharacteristic != null && txCharacteristic != null) &&
1783
- (lc3ReadCharacteristic != null &&
1784
- lc3WriteCharacteristic != null)
2309
+ hasRequiredMentraLiveCoreCharacteristics(
2310
+ hasRxCharacteristic = rxCharacteristic != null,
2311
+ hasTxCharacteristic = txCharacteristic != null,
2312
+ )
2313
+ val hasLc3AudioCharacteristics =
2314
+ lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
1785
2315
 
1786
2316
  if (hasRequiredCharacteristics) {
2317
+ if (!hasLc3AudioCharacteristics) {
2318
+ Bridge.log(
2319
+ "LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
2320
+ )
2321
+ }
1787
2322
  // BLE connection established, but we still need to wait for glasses
1788
2323
  // SOC
1789
2324
  Bridge.log(
1790
- "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX + LC3 TX/RX characteristics verified)"
2325
+ "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
2326
+ hasLc3AudioCharacteristics +
2327
+ ")"
1791
2328
  )
2329
+ markPairingTiming("ble_chars_ready")
1792
2330
  Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1793
2331
 
2332
+ // GATT stack reached real readiness — safe to clear reconnect
2333
+ // backoff.
2334
+ val previousAttempts = reconnectAttempts
2335
+ reconnectAttempts = 0
2336
+ Log.i(
2337
+ TAG,
2338
+ "🔌 ✅ Reconnection counter reset at ble_chars_ready (was at " +
2339
+ previousAttempts +
2340
+ " attempts)"
2341
+ )
2342
+
1794
2343
  // Don't set connected=true here - wait for SOC to be ready
1795
2344
  // (fullyBooted=true)
1796
2345
  // DeviceStore handles connected state based on fullyBooted
@@ -1831,13 +2380,6 @@ class MentraLive : SGCManager() {
1831
2380
  if (txCharacteristic == null) {
1832
2381
  Log.e(TAG, "TX characteristic (peripheral's RX) not found")
1833
2382
  }
1834
- // Log LC3 characteristic errors
1835
- if (lc3ReadCharacteristic == null) {
1836
- Log.e(TAG, "LC3_READ characteristic not found")
1837
- }
1838
- if (lc3WriteCharacteristic == null) {
1839
- Log.e(TAG, "LC3_WRITE characteristic not found")
1840
- }
1841
2383
  gatt.disconnect()
1842
2384
  }
1843
2385
  } else {
@@ -1944,6 +2486,10 @@ class MentraLive : SGCManager() {
1944
2486
  gatt: BluetoothGatt,
1945
2487
  characteristic: BluetoothGattCharacteristic
1946
2488
  ) {
2489
+ if (!isCurrentGattCallback(gatt)) {
2490
+ return
2491
+ }
2492
+
1947
2493
  // Get thread ID for tracking thread issues
1948
2494
  val threadId = Thread.currentThread().id
1949
2495
  val uuid = characteristic.uuid
@@ -1977,20 +2523,6 @@ class MentraLive : SGCManager() {
1977
2523
 
1978
2524
  if (isRxCharacteristic) {
1979
2525
  Bridge.log("LIVE: Received data on RX characteristic")
1980
- // #region agent log [810da2] Hypothesis A+C: capture data.length vs
1981
- // negotiated MTU
1982
- Bridge.log(
1983
- "LIVE: [DEBUG-810da2-HypAC] RX dataLen=" +
1984
- data.size +
1985
- " mtu=" +
1986
- currentMtu +
1987
- " firstByte=0x" +
1988
- String.format("%02X", data[0]) +
1989
- " second=0x" +
1990
- (if (data.size > 1) String.format("%02X", data[1])
1991
- else "??")
1992
- )
1993
- // #endregion
1994
2526
  } else if (isTxCharacteristic) {
1995
2527
  Bridge.log("LIVE: Received data on TX characteristic")
1996
2528
  } else if (isLc3ReadCharacteristic) {
@@ -2095,6 +2627,9 @@ class MentraLive : SGCManager() {
2095
2627
  startSignalStrengthPolling()
2096
2628
  handler.post(processSendQueueRunnable!!)
2097
2629
  startReadinessCheckLoop()
2630
+ // Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
2631
+ // collide during service discovery / descriptor writes.
2632
+ maybeInitiateCtkdAudioAfterBleReady()
2098
2633
  return
2099
2634
  }
2100
2635
 
@@ -3068,25 +3603,6 @@ class MentraLive : SGCManager() {
3068
3603
  processJsonMessage(expanded)
3069
3604
  } else {
3070
3605
  Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
3071
- // #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
3072
- // data length
3073
- val declaredPayloadLen =
3074
- if (data.size >= 5)
3075
- (((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF))
3076
- else -1
3077
- Bridge.log(
3078
- "LIVE: [DEBUG-810da2-HypAB] K900 PARSE FAILED thread=" +
3079
- threadId +
3080
- " dataLen=" +
3081
- data.size +
3082
- " mtu=" +
3083
- currentMtu +
3084
- " declaredPayloadLen=" +
3085
- declaredPayloadLen +
3086
- " expectedTotal=" +
3087
- (declaredPayloadLen + 7)
3088
- )
3089
- // #endregion
3090
3606
  }
3091
3607
 
3092
3608
  return // Exit after processing K900 protocol format
@@ -3260,6 +3776,24 @@ class MentraLive : SGCManager() {
3260
3776
  "pong" ->
3261
3777
  // Process heartbeat pong response
3262
3778
  Bridge.log("LIVE: Received pong response - connection healthy")
3779
+ "entering_pairing_mode" -> {
3780
+ val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
3781
+ Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
3782
+ enterPairingYield(windowMs)
3783
+ val body = HashMap<String, Any>()
3784
+ body["window_ms"] = windowMs
3785
+ body["reason"] = json.optString("reason", "user_gesture")
3786
+ Bridge.sendTypedMessage("entering_pairing_mode", body)
3787
+ }
3788
+ "pairing_info" ->
3789
+ Bridge.sendPairingInfo(
3790
+ json.optBoolean("had_previous_bond", false),
3791
+ jsonNullableString(json, "pairing_code"),
3792
+ json.optBoolean("classic_bond_ready", false),
3793
+ // Legacy firmware that omits this field is not secure-capable.
3794
+ json.optBoolean("secure_pairing_capable", false),
3795
+ json.optInt("protocol_version", 1),
3796
+ )
3263
3797
  "imu_response",
3264
3798
  "imu_stream_response",
3265
3799
  "imu_gesture_response",
@@ -3648,7 +4182,19 @@ class MentraLive : SGCManager() {
3648
4182
  }
3649
4183
  "glasses_ready" -> {
3650
4184
  // Glasses SOC has booted and is ready for communication
4185
+ if (pairingYieldActive && !isConnected) {
4186
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
4187
+ return
4188
+ }
3651
4189
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
4190
+ pairingYieldAwaitingReclaim = false
4191
+ pairingYieldActive = false
4192
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
4193
+ pairingYieldEndRunnable = null
4194
+ markPairingTiming(
4195
+ "glasses_ready",
4196
+ "audioConnected=$audioConnected queueSize=${sendQueue.size}"
4197
+ )
3652
4198
 
3653
4199
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
3654
4200
  // onTransportReset() before sending it, so their side is back on legacy
@@ -3667,9 +4213,9 @@ class MentraLive : SGCManager() {
3667
4213
  // Set the ready flag to stop any future readiness checks
3668
4214
  glassesReady = true
3669
4215
  glassesReadyReceived = true
3670
- // NOTE: Don't set fullyBooted here - it will be set when BOTH glasses_ready
3671
- // AND audioConnected are true (see below). This ensures BT Classic pairing
3672
- // is complete before the device is considered "paired" in MentraOS.
4216
+ // Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
4217
+ // fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
4218
+ // for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
3673
4219
 
3674
4220
  // Stop the readiness check loop since we got confirmation
3675
4221
  stopReadinessCheckLoop()
@@ -3789,19 +4335,21 @@ class MentraLive : SGCManager() {
3789
4335
  Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
3790
4336
  }
3791
4337
 
3792
- // Audio Pairing: Only mark as fully connected if audio is also ready
3793
- // On Android, CTKD automatically pairs BT Classic when BLE bonds, so audio is
3794
- // always ready
3795
- // This check maintains platform parity with iOS
3796
- if (audioConnected) {
4338
+ // Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
4339
+ // Do not wait for CTKD here iOS already marks ready on glasses_ready alone.
4340
+ endPairingTiming(
4341
+ "fully_connected",
4342
+ "via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
4343
+ )
4344
+ DeviceStore.apply("glasses", "fullyBooted", true)
4345
+ updateConnectionState(ConnTypes.CONNECTED)
4346
+ if (!audioConnected) {
3797
4347
  Bridge.log(
3798
- "LIVE: Audio: Both glasses_ready and audio connected - marking as fully connected"
4348
+ "LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
3799
4349
  )
3800
- DeviceStore.apply("glasses", "fullyBooted", true)
3801
- updateConnectionState(ConnTypes.CONNECTED)
3802
- } else {
3803
- Bridge.log(
3804
- "LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"
4350
+ markPairingTiming(
4351
+ "waiting_ctkd_bond",
4352
+ "queueSize=${sendQueue.size}"
3805
4353
  )
3806
4354
  }
3807
4355
  }
@@ -4115,6 +4663,9 @@ class MentraLive : SGCManager() {
4115
4663
  (fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4116
4664
  DeviceStore.apply("glasses", "bluetoothMacAddress", it)
4117
4665
  }
4666
+ (fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4667
+ DeviceStore.apply("glasses", "wifiMacAddress", it)
4668
+ }
4118
4669
  (fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4119
4670
  DeviceStore.apply("glasses", "serialNumber", it)
4120
4671
  }
@@ -4124,6 +4675,12 @@ class MentraLive : SGCManager() {
4124
4675
  DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
4125
4676
  }
4126
4677
  }
4678
+ if (fields.containsKey("hotspot_ota_version")) {
4679
+ val version = fields["hotspot_ota_version"]
4680
+ if (version is Number) {
4681
+ DeviceStore.apply("glasses", "hotspotOtaVersion", version.toInt())
4682
+ }
4683
+ }
4127
4684
 
4128
4685
  // Negotiate wire caps from ANY version_info chunk, not only the one
4129
4686
  // carrying build_number: the glasses put wire_caps in version_info_3
@@ -4480,6 +5037,10 @@ class MentraLive : SGCManager() {
4480
5037
  }
4481
5038
  if (ready == 1) {
4482
5039
  Bridge.log("LIVE: K900 SOC ready")
5040
+ markPairingTiming(
5041
+ "soc_ready",
5042
+ "glassesReady=$glassesReady queueSize=${sendQueue.size}"
5043
+ )
4483
5044
  // Only send phone_ready if we haven't already established connection
4484
5045
  // This prevents re-initialization on every heartbeat after initial
4485
5046
  // connection
@@ -5187,6 +5748,8 @@ class MentraLive : SGCManager() {
5187
5748
 
5188
5749
  /** Start the micbeat mechanism - periodically enable custom audio TX */
5189
5750
  private fun startMicBeat() {
5751
+ // A resumed custom-audio stream starts a new sequence domain.
5752
+ DeviceManager.getInstance().resetMicSequenceBaseline()
5190
5753
  micOnCount++
5191
5754
  Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
5192
5755
  micBeatCount = 0
@@ -5328,8 +5891,16 @@ class MentraLive : SGCManager() {
5328
5891
  override fun findCompatibleDevices() {
5329
5892
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5330
5893
 
5331
- // Clear reconnection mode when user manually scans
5894
+ // A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
5895
+ // and replace any active fast reconnect scan with discovery mode.
5896
+ reconnectGeneration++
5897
+ if (isScanning) {
5898
+ stopScan()
5899
+ }
5900
+ manualDiscoveryActive = true
5332
5901
  isReconnecting = false
5902
+ // Discovery-only: do not treat leftover savedDeviceName as a connect target.
5903
+ explicitConnectByName = false
5333
5904
 
5334
5905
  if (bluetoothAdapter == null) {
5335
5906
  Log.e(TAG, "Bluetooth not available")
@@ -5341,50 +5912,123 @@ class MentraLive : SGCManager() {
5341
5912
  return
5342
5913
  }
5343
5914
 
5915
+ // Stale Classic ACL keeps Mentra Live from advertising — drop A2DP/HFP (keep bond)
5916
+ // so a pairing/discovery scan can see the unit again. Keep the active session's
5917
+ // audio; tear down other bonded Lives so a second unit can advertise.
5918
+ releaseStaleClassicForDiscovery()
5919
+
5344
5920
  // Start scanning for BLE devices
5345
5921
  startScan()
5346
5922
  }
5347
5923
 
5924
+ private fun releaseStaleClassicForDiscovery() {
5925
+ val adapter = bluetoothAdapter ?: return
5926
+ val bonded =
5927
+ try {
5928
+ adapter.bondedDevices?.toList().orEmpty()
5929
+ } catch (e: Exception) {
5930
+ Bridge.log("LIVE: Classic: discovery release — cannot list bonded: " + e.message)
5931
+ return
5932
+ }
5933
+ val mentraBonded =
5934
+ bonded.filter { isMentraLiveBluetoothName(safeDeviceName(it)) }
5935
+ if (mentraBonded.isEmpty()) {
5936
+ return
5937
+ }
5938
+ val currentAddress = connectedDevice?.address
5939
+ for (device in mentraBonded) {
5940
+ if (currentAddress != null && device.address.equals(currentAddress, ignoreCase = true)) {
5941
+ continue
5942
+ }
5943
+ Bridge.log(
5944
+ "LIVE: Classic: releasing stale A2DP/HFP before discovery for " +
5945
+ safeDeviceName(device) +
5946
+ " (" +
5947
+ device.address +
5948
+ ")"
5949
+ )
5950
+ disconnectClassicProfiles(device)
5951
+ }
5952
+ }
5953
+
5348
5954
  override fun connectById(id: String) {
5955
+ if (pairingYieldActive) {
5956
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
5957
+ return
5958
+ }
5959
+ reconnectGeneration++
5960
+ manualDiscoveryActive = false
5961
+ if (isScanning) {
5962
+ stopScan()
5963
+ }
5964
+ isReconnecting = false
5349
5965
  Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
5350
5966
  savedDeviceName = id
5351
- // // Persist immediately so reconnection logic can find it in case this connection fails
5352
- // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5353
- // prefs.edit().putString(PREF_DEVICE_NAME, id).apply();
5354
- // Log.i(TAG, "🔌 💾 Saved device name for future reconnection: " +
5355
- // connectedDevice.getName());
5356
- // Bridge.log("LIVE: Saved device name for future reconnection: " +
5357
- // connectedDevice.getName());
5967
+ // Without this, connectToSmartGlasses() falls back to a name scan with
5968
+ // isReconnecting=false and never GATT-connects (loading screen hangs).
5969
+ explicitConnectByName = true
5358
5970
  connectToSmartGlasses()
5359
5971
  }
5360
5972
 
5973
+ /**
5974
+ * True while Android is showing (or about to show) the system pairing dialog for
5975
+ * CTKD Classic bonding. Calling [removeBond] / tearing down the SGC here aborts that
5976
+ * dialog and forces a full re-pair.
5977
+ */
5978
+ fun isCtkdBondingInProgress(): Boolean {
5979
+ val device = connectedDevice ?: return false
5980
+ return device.bondState == BluetoothDevice.BOND_BONDING
5981
+ }
5982
+
5361
5983
  override fun forget() {
5362
5984
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
5363
5985
 
5364
- // Clear saved device name to prevent reconnection to this device
5365
- // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5366
- // prefs.edit().remove(PREF_DEVICE_NAME).apply();
5367
- // Bridge.log("LIVE: Cleared saved device name");
5986
+ // Clear saved device name so a leftover name can't bypass the pairing-mode
5987
+ // discovery filter for this unit on a subsequent rescan (parity with iOS forget()).
5988
+ savedDeviceName = ""
5368
5989
 
5369
5990
  // Reset reconnection state
5370
5991
  reconnectAttempts = 0
5371
5992
  isReconnecting = false
5993
+ manualDiscoveryActive = false
5994
+ explicitConnectByName = false
5372
5995
 
5373
- // Remove BT Classic bond - this is the ONLY place where we unbond,
5374
- // ensuring bond is only removed when user explicitly unpairs
5375
- if (connectedDevice != null) {
5376
- Bridge.log("LIVE: CTKD: Removing BT bond on explicit unpair")
5377
- removeBond(connectedDevice!!)
5378
- }
5379
-
5996
+ val ctx = context ?: Bridge.getContext()
5997
+ val bondedAddress = connectedDevice?.address
5380
5998
  if (isScanning) {
5381
5999
  stopScan()
5382
6000
  emitStopScanEvent()
5383
6001
  }
5384
- disconnect()
6002
+ // Tell glasses to drop Mentra owner before tearing the link. removeBond
6003
+ // alone leaves glasses NV owned, so the next pair hits OWNER_ONLY.
6004
+ try {
6005
+ val unpair = JSONObject()
6006
+ unpair.put("type", "unpair")
6007
+ sendJsonWithoutAck(unpair, true)
6008
+ } catch (e: JSONException) {
6009
+ Log.e(TAG, "Error creating unpair command", e)
6010
+ }
6011
+ // Give the unpair write a beat to leave GATT before destroy() cancels the queue.
6012
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
6013
+ unpairFlushPending = true
6014
+ val flush =
6015
+ Runnable {
6016
+ unpairFlushPending = false
6017
+ unpairFlushRunnable = null
6018
+ // Drop GATT first. removeBond while the BLE link is still encrypted often
6019
+ // returns true on Samsung but the OS Bluetooth UI keeps the dual-mode pair.
6020
+ disconnect()
6021
+ unbondBondedMentraLiveDevices(ctx, bondedAddress)
6022
+ }
6023
+ unpairFlushRunnable = flush
6024
+ handler.postDelayed(flush, UNPAIR_FLUSH_MS)
5385
6025
  }
5386
6026
 
5387
6027
  override fun disconnect() {
6028
+ if (unpairFlushPending) {
6029
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6030
+ return
6031
+ }
5388
6032
  Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
5389
6033
  destroy()
5390
6034
  }
@@ -5422,6 +6066,10 @@ class MentraLive : SGCManager() {
5422
6066
  }
5423
6067
 
5424
6068
  fun connectToSmartGlasses() {
6069
+ if (pairingYieldActive) {
6070
+ Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6071
+ return
6072
+ }
5425
6073
  Bridge.log("LIVE: Connecting to Mentra Live glasses")
5426
6074
  updateConnectionState(ConnTypes.CONNECTING)
5427
6075
 
@@ -5450,7 +6098,13 @@ class MentraLive : SGCManager() {
5450
6098
  // var context = Bridge.getContext();
5451
6099
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5452
6100
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
5453
- val lastDeviceAddress = DeviceStore.get("bluetooth", "device_address") as String?
6101
+ val pendingAddress =
6102
+ (DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
6103
+ it.isNotEmpty()
6104
+ }
6105
+ val lastDeviceAddress =
6106
+ pendingAddress
6107
+ ?: (DeviceStore.get("bluetooth", "device_address") as String?)
5454
6108
 
5455
6109
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
5456
6110
  // Connect to last known device if available
@@ -5461,6 +6115,7 @@ class MentraLive : SGCManager() {
5461
6115
  Bridge.log(
5462
6116
  "LIVE: Found saved device, connecting directly: " + lastDeviceAddress
5463
6117
  )
6118
+ prepareClassicBeforeBleConnect(device)
5464
6119
  connectToDevice(device)
5465
6120
  } else {
5466
6121
  Bridge.log(
@@ -5476,12 +6131,62 @@ class MentraLive : SGCManager() {
5476
6131
  startScan() // Fallback to scanning
5477
6132
  }
5478
6133
  } else {
5479
- // If no last known device, start scanning for devices
5480
- Bridge.log("LIVE: No last known device, starting scan")
5481
- startScan()
6134
+ // Pairing may only have a name (or address wasn't stashed). Prefer a bonded
6135
+ // device match over scan Classic-connected glasses often stop advertising.
6136
+ val bonded = findBondedDeviceByName(savedDeviceName)
6137
+ if (bonded != null) {
6138
+ Bridge.log(
6139
+ "LIVE: No saved address — using bonded device " +
6140
+ safeDeviceName(bonded) +
6141
+ " (" +
6142
+ bonded.address +
6143
+ ")"
6144
+ )
6145
+ prepareClassicBeforeBleConnect(bonded)
6146
+ connectToDevice(bonded)
6147
+ } else {
6148
+ Bridge.log("LIVE: No last known device, starting scan")
6149
+ startScan()
6150
+ }
6151
+ }
6152
+ }
6153
+
6154
+ private fun findBondedDeviceByName(name: String): BluetoothDevice? {
6155
+ if (name.isEmpty() || bluetoothAdapter == null) {
6156
+ return null
6157
+ }
6158
+ return try {
6159
+ bluetoothAdapter!!.bondedDevices?.firstOrNull { safeDeviceName(it) == name }
6160
+ } catch (e: SecurityException) {
6161
+ Bridge.log("LIVE: Unable to read bonded devices: " + e.message)
6162
+ null
6163
+ } catch (e: Exception) {
6164
+ Bridge.log("LIVE: Error reading bonded devices: " + e.message)
6165
+ null
5482
6166
  }
5483
6167
  }
5484
6168
 
6169
+ /**
6170
+ * Fresh pairing / discovery may need Classic down so glasses leave a stuck ACL.
6171
+ * Owner reconnect must NOT tear Classic first: BES BLE-PAIR-GUARD historically
6172
+ * admitted Mentra App RPAs only while Classic owner ACL was up — tearing it
6173
+ * caused GATT status=19 reject loops after Mentra App disconnect.
6174
+ */
6175
+ private fun prepareClassicBeforeBleConnect(device: BluetoothDevice) {
6176
+ if (device.bondState != BluetoothDevice.BOND_BONDED) {
6177
+ return
6178
+ }
6179
+ // Owner reconnect and connectById both go through connectToSmartGlasses(),
6180
+ // which clears isReconnecting first. Leave Classic up: tearing A2DP/HFP
6181
+ // here historically caused GATT status=19 reject loops. Discovery teardown
6182
+ // lives in releaseStaleClassicForDiscovery() for pairing scans only.
6183
+ Bridge.log(
6184
+ "LIVE: Classic: bonded target — leave A2DP/HFP up for BLE admit (" +
6185
+ safeDeviceName(device) +
6186
+ ")"
6187
+ )
6188
+ }
6189
+
5485
6190
  override fun setMicEnabled(enabled: Boolean) {
5486
6191
  Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
5487
6192
 
@@ -5925,41 +6630,67 @@ class MentraLive : SGCManager() {
5925
6630
  Bridge.log(
5926
6631
  "LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
5927
6632
  )
6633
+ markPairingTiming(
6634
+ "ctkd_bonded",
6635
+ "glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
6636
+ )
5928
6637
  if (isKilled) {
5929
6638
  Bridge.log(
5930
6639
  "LIVE: CTKD: Ignoring bond complete — SGC destroyed"
5931
6640
  )
5932
6641
  return@run
5933
6642
  }
5934
- isBtClassicConnected = true
5935
- audioConnected = true
5936
6643
  bondingRetryCount =
5937
6644
  0 // Reset retry counter on success
5938
- // Both BLE and BT Classic are now connected via
5939
- // CTKD
5940
-
5941
- // If glasses_ready was already received, now we're
5942
- // fully ready
6645
+ // Bond != audio profile. Explicitly open A2DP
6646
+ // after createBond() completes (already-bonded
6647
+ // path does this at GATT connect).
6648
+ // markAudioConnected / sendAudioConnected fire
6649
+ // when the A2DP path completes.
6650
+ connectA2dpProfile(device)
6651
+
6652
+ // Safety net: if glasses_ready somehow missed
6653
+ // setting fullyBooted, catch up here.
5943
6654
  if (glassesReadyReceived) {
5944
- Bridge.log(
5945
- "LIVE: Audio: Both audio and glasses_ready confirmed - marking as fully connected"
5946
- )
5947
- DeviceStore.apply(
5948
- "glasses",
5949
- "fullyBooted",
5950
- true
5951
- )
5952
- updateConnectionState(ConnTypes.CONNECTED)
6655
+ val alreadyBooted =
6656
+ DeviceStore.store.get(
6657
+ "glasses",
6658
+ "fullyBooted"
6659
+ ) as? Boolean
6660
+ ?: false
6661
+ if (!alreadyBooted) {
6662
+ Bridge.log(
6663
+ "LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
6664
+ )
6665
+ endPairingTiming(
6666
+ "fully_connected",
6667
+ "via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
6668
+ )
6669
+ DeviceStore.apply(
6670
+ "glasses",
6671
+ "fullyBooted",
6672
+ true
6673
+ )
6674
+ updateConnectionState(ConnTypes.CONNECTED)
6675
+ } else {
6676
+ Bridge.log(
6677
+ "LIVE: Audio: CTKD bonded after pairing UI already advanced"
6678
+ )
6679
+ markPairingTiming(
6680
+ "ctkd_bonded_after_fully_connected",
6681
+ "queueSize=${sendQueue.size}"
6682
+ )
6683
+ }
5953
6684
  }
5954
-
5955
- // Send audio connected event for platform parity
5956
- // with iOS
5957
- Bridge.sendAudioConnected(device.name)
5958
6685
  }
5959
6686
  BluetoothDevice.BOND_NONE -> {
5960
6687
  Bridge.log(
5961
6688
  "LIVE: CTKD: ❌ Bonding failed or removed for device"
5962
6689
  )
6690
+ markPairingTiming(
6691
+ "ctkd_bond_none",
6692
+ "prev=$previousBondState queueSize=${sendQueue.size}"
6693
+ )
5963
6694
  isBtClassicConnected = false
5964
6695
  audioConnected = false
5965
6696
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
@@ -6020,10 +6751,12 @@ class MentraLive : SGCManager() {
6020
6751
  Bridge.sendAudioDisconnected()
6021
6752
  }
6022
6753
  }
6023
- BluetoothDevice.BOND_BONDING ->
6754
+ BluetoothDevice.BOND_BONDING -> {
6024
6755
  Bridge.log(
6025
6756
  "LIVE: CTKD: 🔄 Bonding in progress with device"
6026
6757
  )
6758
+ markPairingTiming("ctkd_bonding")
6759
+ }
6027
6760
  else ->
6028
6761
  Bridge.log(
6029
6762
  "LIVE: CTKD: Unknown bond state: " + bondState
@@ -6058,6 +6791,39 @@ class MentraLive : SGCManager() {
6058
6791
  }
6059
6792
  }
6060
6793
 
6794
+ /**
6795
+ * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
6796
+ * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
6797
+ * (peer terminate) on reconnect when A2DP is already up.
6798
+ */
6799
+ private fun maybeInitiateCtkdAudioAfterBleReady() {
6800
+ if (isKilled) {
6801
+ return
6802
+ }
6803
+ val device = connectedDevice
6804
+ if (device == null) {
6805
+ Bridge.log("LIVE: CTKD: Skipping initiate — no connected device")
6806
+ return
6807
+ }
6808
+ if (ctkdInitiatedThisGattSession) {
6809
+ return
6810
+ }
6811
+ ctkdInitiatedThisGattSession = true
6812
+ Bridge.log(
6813
+ "LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
6814
+ device.bondState +
6815
+ ")"
6816
+ )
6817
+ markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
6818
+ if (device.bondState == BluetoothDevice.BOND_BONDED) {
6819
+ Bridge.log("LIVE: CTKD: Device is already bonded - connecting A2DP audio profile")
6820
+ markPairingTiming("ctkd_already_bonded")
6821
+ connectA2dpProfile(device)
6822
+ } else {
6823
+ createBond(device)
6824
+ }
6825
+ }
6826
+
6061
6827
  /**
6062
6828
  * Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
6063
6829
  * BLE and BT Classic connections automatically
@@ -6164,6 +6930,7 @@ class MentraLive : SGCManager() {
6164
6930
 
6165
6931
  if (state == BluetoothProfile.STATE_CONNECTED) {
6166
6932
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
6933
+ a2dpConnectAttempts = 0
6167
6934
  markAudioConnected(device.name)
6168
6935
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
6169
6936
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
@@ -6172,37 +6939,50 @@ class MentraLive : SGCManager() {
6172
6939
  BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
6173
6940
  val result = connectMethod.invoke(a2dpProfile, device) as Boolean
6174
6941
  Bridge.log("LIVE: A2DP: Connect initiated, result: " + result)
6175
-
6176
- // Note: connect() is async. We mark as connected optimistically because:
6177
- // 1. The device is already bonded, so connection should succeed
6178
- // 2. Android will handle the actual A2DP connection in the background
6179
- // 3. If it fails, the user can still use BLE audio (LC3)
6180
- markAudioConnected(device.name)
6942
+ if (result) {
6943
+ scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
6944
+ } else {
6945
+ scheduleA2dpConnectRetry(device, "connect() returned false")
6946
+ }
6181
6947
  } else if (state == BluetoothProfile.STATE_CONNECTING) {
6182
- Bridge.log("LIVE: A2DP: Already connecting, marking audio as connected")
6183
- markAudioConnected(device.name)
6948
+ Bridge.log("LIVE: A2DP: Already connecting waiting for STATE_CONNECTED")
6949
+ scheduleA2dpConnectRetry(device, "still connecting")
6184
6950
  } else {
6185
- // STATE_DISCONNECTING - wait and retry
6186
- Bridge.log("LIVE: A2DP: Device disconnecting, will retry in 500ms")
6187
- handler.postDelayed(
6188
- Runnable {
6189
- if (isKilled) {
6190
- return@Runnable
6191
- }
6192
- if (connectedDevice != null && a2dpProfile != null) {
6193
- connectA2dpWithProxy(connectedDevice)
6194
- }
6195
- },
6196
- 500
6197
- )
6951
+ scheduleA2dpConnectRetry(device, "device disconnecting")
6198
6952
  }
6199
6953
  } catch (e: Exception) {
6200
6954
  Bridge.log("LIVE: A2DP: Error connecting: " + e.message)
6201
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6202
- markAudioConnected(device.name)
6955
+ scheduleA2dpConnectRetry(device, "exception: ${e.message}")
6203
6956
  }
6204
6957
  }
6205
6958
 
6959
+ private fun scheduleA2dpConnectRetry(device: BluetoothDevice, reason: String) {
6960
+ a2dpConnectAttempts++
6961
+ if (a2dpConnectAttempts > A2DP_CONNECT_MAX_ATTEMPTS) {
6962
+ Bridge.log(
6963
+ "LIVE: A2DP: Giving up after $A2DP_CONNECT_MAX_ATTEMPTS attempts ($reason). " +
6964
+ "Classic bond may exist, but A2DP/HFP is not up. BLE/LC3 still works."
6965
+ )
6966
+ return
6967
+ }
6968
+ Bridge.log(
6969
+ "LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
6970
+ "${A2DP_CONNECT_RETRY_MS}ms ($reason)"
6971
+ )
6972
+ handler.postDelayed(
6973
+ Runnable {
6974
+ if (isKilled || pairingYieldActive || connectedDevice == null) {
6975
+ return@Runnable
6976
+ }
6977
+ val target = connectedDevice ?: return@Runnable
6978
+ if (a2dpProfile != null) {
6979
+ connectA2dpWithProxy(target)
6980
+ }
6981
+ },
6982
+ A2DP_CONNECT_RETRY_MS
6983
+ )
6984
+ }
6985
+
6206
6986
  /** Helper to mark audio as connected and notify */
6207
6987
  private fun markAudioConnected(deviceName: String?) {
6208
6988
  if (isKilled) {
@@ -6218,8 +6998,14 @@ class MentraLive : SGCManager() {
6218
6998
  Bridge.log(
6219
6999
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
6220
7000
  )
7001
+ endPairingTiming(
7002
+ "fully_connected",
7003
+ "via=a2dp+glasses_ready queueSize=${sendQueue.size}"
7004
+ )
6221
7005
  DeviceStore.apply("glasses", "fullyBooted", true)
6222
7006
  updateConnectionState(ConnTypes.CONNECTED)
7007
+ } else {
7008
+ markPairingTiming("a2dp_connected_waiting_glasses_ready")
6223
7009
  }
6224
7010
  }
6225
7011
 
@@ -6243,6 +7029,7 @@ class MentraLive : SGCManager() {
6243
7029
  }
6244
7030
 
6245
7031
  Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
7032
+ a2dpConnectAttempts = 0
6246
7033
 
6247
7034
  // If we already have the proxy, try to connect directly
6248
7035
  if (a2dpProfile != null) {
@@ -6263,16 +7050,133 @@ class MentraLive : SGCManager() {
6263
7050
  Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
6264
7051
  } else {
6265
7052
  Bridge.log(
6266
- "LIVE: A2DP: Failed to get profile proxy, marking audio connected anyway"
7053
+ "LIVE: A2DP: Failed to get profile proxy Classic audio not connected"
6267
7054
  )
6268
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6269
- markAudioConnected(device.name)
6270
7055
  }
6271
7056
  } else {
6272
7057
  Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
6273
7058
  }
6274
7059
  }
6275
7060
 
7061
+ /**
7062
+ * Drop Classic audio profiles (A2DP + HFP). App disconnect previously only closed BLE GATT /
7063
+ * the A2DP proxy, so Android Settings still showed Mentra Live as connected and the next BLE
7064
+ * reconnect fought a live Classic link (GATT 19). Bond is intentionally kept for reconnect —
7065
+ * only [forget] calls removeBond.
7066
+ */
7067
+ private fun disconnectClassicProfiles(device: BluetoothDevice?) {
7068
+ if (device == null || bluetoothAdapter == null || context == null) {
7069
+ Bridge.log("LIVE: Classic: skip profile teardown — missing device/adapter/context")
7070
+ return
7071
+ }
7072
+ Bridge.log(
7073
+ "LIVE: Classic: tearing down A2DP/HFP for " +
7074
+ safeDeviceName(device) +
7075
+ " (" +
7076
+ device.address +
7077
+ ")"
7078
+ )
7079
+ val isActiveDevice =
7080
+ connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7081
+ if (isActiveDevice) {
7082
+ isBtClassicConnected = false
7083
+ audioConnected = false
7084
+ }
7085
+
7086
+ if (a2dpProfile != null) {
7087
+ invokeHiddenProfileDisconnect(
7088
+ a2dpProfile!!,
7089
+ device,
7090
+ BluetoothA2dp::class.java,
7091
+ "A2DP"
7092
+ )
7093
+ } else {
7094
+ requestProfileDisconnect(BluetoothProfile.A2DP, device, BluetoothA2dp::class.java, "A2DP")
7095
+ }
7096
+ // HFP/SCO often stays up after A2DP-only teardown (shows as "Bluetooth Headset" in routes).
7097
+ requestProfileDisconnect(
7098
+ BluetoothProfile.HEADSET,
7099
+ device,
7100
+ BluetoothHeadset::class.java,
7101
+ "HFP"
7102
+ )
7103
+ }
7104
+
7105
+ private fun invokeHiddenProfileDisconnect(
7106
+ proxy: BluetoothProfile,
7107
+ device: BluetoothDevice,
7108
+ proxyClass: Class<*>,
7109
+ label: String
7110
+ ) {
7111
+ try {
7112
+ val state = proxy.getConnectionState(device)
7113
+ Bridge.log("LIVE: Classic: " + label + " state=" + state)
7114
+ if (state == BluetoothProfile.STATE_CONNECTED ||
7115
+ state == BluetoothProfile.STATE_CONNECTING
7116
+ ) {
7117
+ val disconnectMethod =
7118
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
7119
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
7120
+ Bridge.log("LIVE: Classic: " + label + " disconnect result=" + result)
7121
+ } else {
7122
+ Bridge.log("LIVE: Classic: " + label + " already idle — nothing to disconnect")
7123
+ }
7124
+ } catch (e: Exception) {
7125
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
7126
+ }
7127
+ }
7128
+
7129
+ private fun requestProfileDisconnect(
7130
+ profile: Int,
7131
+ device: BluetoothDevice,
7132
+ proxyClass: Class<*>,
7133
+ label: String
7134
+ ) {
7135
+ val adapter = bluetoothAdapter ?: return
7136
+ val ctx = context ?: return
7137
+ try {
7138
+ val ok =
7139
+ adapter.getProfileProxy(
7140
+ ctx,
7141
+ object : BluetoothProfile.ServiceListener {
7142
+ override fun onServiceConnected(
7143
+ profileType: Int,
7144
+ proxy: BluetoothProfile
7145
+ ) {
7146
+ if (profileType != profile) {
7147
+ return
7148
+ }
7149
+ try {
7150
+ invokeHiddenProfileDisconnect(
7151
+ proxy,
7152
+ device,
7153
+ proxyClass,
7154
+ label
7155
+ )
7156
+ } finally {
7157
+ try {
7158
+ adapter.closeProfileProxy(profile, proxy)
7159
+ } catch (e: Exception) {
7160
+ Bridge.log(
7161
+ "LIVE: Classic: error closing " +
7162
+ label +
7163
+ " proxy: " +
7164
+ e.message
7165
+ )
7166
+ }
7167
+ }
7168
+ }
7169
+
7170
+ override fun onServiceDisconnected(profileType: Int) {}
7171
+ },
7172
+ profile
7173
+ )
7174
+ Bridge.log("LIVE: Classic: requested " + label + " proxy for disconnect, ok=" + ok)
7175
+ } catch (e: Exception) {
7176
+ Bridge.log("LIVE: Classic: failed to request " + label + " proxy: " + e.message)
7177
+ }
7178
+ }
7179
+
6276
7180
  /** Close the A2DP profile proxy */
6277
7181
  private fun closeA2dpProxy() {
6278
7182
  if (a2dpProfile != null && bluetoothAdapter != null) {
@@ -6288,6 +7192,14 @@ class MentraLive : SGCManager() {
6288
7192
 
6289
7193
  // Mark as killed to prevent reconnection attempts
6290
7194
  isKilled = true
7195
+ explicitConnectByName = false
7196
+ pairingYieldActive = false
7197
+ pairingYieldAwaitingReclaim = false
7198
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
7199
+ pairingYieldEndRunnable = null
7200
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
7201
+ unpairFlushRunnable = null
7202
+ unpairFlushPending = false
6291
7203
 
6292
7204
  // Stop scanning if in progress
6293
7205
  if (isScanning) {
@@ -6298,7 +7210,10 @@ class MentraLive : SGCManager() {
6298
7210
  // CTKD Implementation: Unregister bonding receiver
6299
7211
  unregisterBondingReceiver()
6300
7212
 
6301
- // Close A2DP profile proxy
7213
+ // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7214
+ // phone's system Bluetooth UI showing Mentra Live still connected.
7215
+ val classicDevice = connectedDevice
7216
+ disconnectClassicProfiles(classicDevice)
6302
7217
  closeA2dpProxy()
6303
7218
 
6304
7219
  // Stop readiness check loop
@@ -6369,6 +7284,9 @@ class MentraLive : SGCManager() {
6369
7284
  reconnectAttempts = 0
6370
7285
  isReconnecting = false
6371
7286
  glassesReady = false
7287
+ glassesReadyReceived = false
7288
+ audioConnected = false
7289
+ ctkdInitiatedThisGattSession = false
6372
7290
  DeviceStore.apply("glasses", "fullyBooted", false)
6373
7291
  updateConnectionState(ConnTypes.DISCONNECTED)
6374
7292
 
@@ -7388,6 +8306,7 @@ class MentraLive : SGCManager() {
7388
8306
  /** Sends phone_ready to (re)start the glasses-driven readiness flow. */
7389
8307
  private fun sendPhoneReady(reason: String) {
7390
8308
  Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
8309
+ markPairingTiming("phone_ready_sent", "reason=$reason queueSize=${sendQueue.size}")
7391
8310
  val readyMsg = JSONObject()
7392
8311
  readyMsg.put("type", "phone_ready")
7393
8312
  readyMsg.put("timestamp", System.currentTimeMillis())
@@ -7511,6 +8430,10 @@ class MentraLive : SGCManager() {
7511
8430
  payload
7512
8431
  )
7513
8432
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
8433
+ markPairingTiming(
8434
+ "wire_handshake_sent",
8435
+ "attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
8436
+ )
7514
8437
  wireHandshakeQueued = true
7515
8438
  wireHandshakeSentGeneration = wireSessionGeneration
7516
8439
  queueData(packed, null)
@@ -7537,6 +8460,10 @@ class MentraLive : SGCManager() {
7537
8460
  WIRE_HANDSHAKE_MAX_ATTEMPTS +
7538
8461
  ") - re-arming"
7539
8462
  )
8463
+ markPairingTiming(
8464
+ "wire_handshake_unanswered",
8465
+ "attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
8466
+ )
7540
8467
  wireHandshakeQueued = false
7541
8468
  if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7542
8469
  maybeSendWireHandshake()
@@ -7565,6 +8492,7 @@ class MentraLive : SGCManager() {
7565
8492
  Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7566
8493
  return
7567
8494
  }
8495
+ markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
7568
8496
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7569
8497
  }
7570
8498
 
@@ -8238,6 +9166,19 @@ class MentraLive : SGCManager() {
8238
9166
  }
8239
9167
  }
8240
9168
 
9169
+ override fun sendWifiAdbState(enabled: Boolean) {
9170
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state to glasses - enabled: " + enabled)
9171
+ try {
9172
+ val command = JSONObject()
9173
+ command.put("type", "set_wifi_adb_state")
9174
+ command.put("enabled", enabled)
9175
+ sendJson(command, true)
9176
+ Bridge.log("LIVE: 🔧 ✅ Wi-Fi ADB state command sent successfully")
9177
+ } catch (e: JSONException) {
9178
+ Log.e(TAG, "🔧 💥 Error creating Wi-Fi ADB state JSON", e)
9179
+ }
9180
+ }
9181
+
8241
9182
  override fun sendSetSystemTime(timestampMs: Long) {
8242
9183
  Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
8243
9184
  try {
@@ -9425,6 +10366,21 @@ class MentraLive : SGCManager() {
9425
10366
  stopVideoRecording(requestId, null, null)
9426
10367
  }
9427
10368
 
10369
+ override fun queryVideoRecordingStatus(requestId: String) {
10370
+ if (!isConnected) {
10371
+ Log.w(TAG, "Cannot query video recording status - not connected")
10372
+ return
10373
+ }
10374
+ try {
10375
+ val json = JSONObject()
10376
+ json.put("type", "get_video_recording_status")
10377
+ json.put("requestId", requestId)
10378
+ sendJson(json, true)
10379
+ } catch (e: JSONException) {
10380
+ Log.e(TAG, "Failed to create video recording status query", e)
10381
+ }
10382
+ }
10383
+
9428
10384
  override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
9429
10385
  Bridge.log(
9430
10386
  "LIVE: Stopping video recording: requestId=" +
@@ -9475,20 +10431,6 @@ class MentraLive : SGCManager() {
9475
10431
  val sequenceNumber = data[1]
9476
10432
  val receiveTime = System.currentTimeMillis()
9477
10433
 
9478
- // Basic sequence validation
9479
- if (lastReceivedLc3Sequence != (-1).toByte() &&
9480
- (lastReceivedLc3Sequence + 1).toByte() != sequenceNumber
9481
- ) {
9482
- Log.w(
9483
- TAG,
9484
- "LC3 packet sequence mismatch. Expected: " +
9485
- (lastReceivedLc3Sequence + 1) +
9486
- ", Got: " +
9487
- sequenceNumber
9488
- )
9489
- }
9490
- lastReceivedLc3Sequence = sequenceNumber
9491
-
9492
10434
  val lc3Data = Arrays.copyOfRange(data, 2, data.size)
9493
10435
 
9494
10436
  // Enhanced LC3 packet logging and saving
@@ -9500,7 +10442,11 @@ class MentraLive : SGCManager() {
9500
10442
 
9501
10443
  // Forward raw LC3 to DeviceManager (matches iOS behavior)
9502
10444
  // MentraLive uses 40-byte LC3 frames
9503
- DeviceManager.getInstance().handleGlassesMicData(lc3Data, LC3_FRAME_SIZE)
10445
+ DeviceManager.getInstance().handleGlassesMicData(
10446
+ lc3Data,
10447
+ LC3_FRAME_SIZE,
10448
+ sequenceNumber.toInt() and 0xff,
10449
+ )
9504
10450
 
9505
10451
  // Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
9506
10452
  // } else {