@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-dev.4

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 +38 -2
  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 +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +209 -21
  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/BesOtaHeartbeatGuard.kt +49 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1381 -284
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  29. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  37. package/build/BluetoothSdk.types.d.ts +63 -2
  38. package/build/BluetoothSdk.types.d.ts.map +1 -1
  39. package/build/BluetoothSdk.types.js.map +1 -1
  40. package/build/_internal.d.ts +2 -0
  41. package/build/_internal.d.ts.map +1 -1
  42. package/build/_internal.js +2 -0
  43. package/build/_internal.js.map +1 -1
  44. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  45. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  46. package/build/_private/BluetoothSdkModule.js +1 -1
  47. package/build/_private/BluetoothSdkModule.js.map +1 -1
  48. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  49. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  50. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  51. package/build/_private/sdkOtaManifest.d.ts +3 -12
  52. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  53. package/build/_private/sdkOtaManifest.js +5 -17
  54. package/build/_private/sdkOtaManifest.js.map +1 -1
  55. package/build/generated/releaseMetadata.d.ts +12 -0
  56. package/build/generated/releaseMetadata.d.ts.map +1 -0
  57. package/build/generated/releaseMetadata.js +10 -0
  58. package/build/generated/releaseMetadata.js.map +1 -0
  59. package/build/index.d.ts +1 -1
  60. package/build/index.d.ts.map +1 -1
  61. package/build/index.js +10 -2
  62. package/build/index.js.map +1 -1
  63. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  64. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServer.types.js +2 -0
  66. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  68. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.js +3 -0
  70. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  72. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  73. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  74. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  75. package/build/ota-server/index.d.ts +3 -0
  76. package/build/ota-server/index.d.ts.map +1 -0
  77. package/build/ota-server/index.js +3 -0
  78. package/build/ota-server/index.js.map +1 -0
  79. package/expo-module.config.json +3 -2
  80. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  81. package/ios/BluetoothSdkModule.swift +16 -2
  82. package/ios/LocalIPv4.swift +150 -0
  83. package/ios/LocalOtaServer.swift +309 -0
  84. package/ios/MentraBluetoothSDK.podspec +5 -1
  85. package/ios/MentraOtaServerModule.swift +147 -0
  86. package/ios/MentraPhotoReceiverModule.swift +1 -81
  87. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  88. package/ios/Source/Bridge.swift +47 -3
  89. package/ios/Source/DeviceManager.swift +146 -16
  90. package/ios/Source/DeviceStore.swift +2 -0
  91. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  92. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  93. package/ios/Source/OtaManifest.swift +5 -7
  94. package/ios/Source/audio/AudioModels.swift +17 -0
  95. package/ios/Source/camera/CameraModels.swift +1 -0
  96. package/ios/Source/events/BluetoothEvents.swift +27 -0
  97. package/ios/Source/sgcs/MentraLive.swift +498 -77
  98. package/ios/Source/sgcs/SGCManager.swift +11 -0
  99. package/ios/Source/status/DeviceStatus.swift +23 -0
  100. package/ios/Source/types/DeviceModels.swift +23 -2
  101. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  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 +74 -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
@@ -27,6 +28,7 @@ import android.os.Build
27
28
  import android.os.Handler
28
29
  import android.os.HandlerThread
29
30
  import android.os.Looper
31
+ import android.os.SystemClock
30
32
  import android.util.Log
31
33
  import androidx.core.app.ActivityCompat
32
34
  import com.mentra.bluetoothsdk.BluetoothSdkDefaults
@@ -75,6 +77,11 @@ import org.json.JSONArray
75
77
  import org.json.JSONException
76
78
  import org.json.JSONObject
77
79
 
80
+ internal fun hasRequiredMentraLiveCoreCharacteristics(
81
+ hasRxCharacteristic: Boolean,
82
+ hasTxCharacteristic: Boolean,
83
+ ): Boolean = hasRxCharacteristic && hasTxCharacteristic
84
+
78
85
  /**
79
86
  * Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
80
87
  * glasses
@@ -82,7 +89,14 @@ import org.json.JSONObject
82
89
  * Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
83
90
  * display-related methods are stubbed out and will log a message but not actually display anything.
84
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
+
85
98
  class MentraLive : SGCManager() {
99
+ private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
86
100
 
87
101
  companion object {
88
102
  private const val TAG = "Live"
@@ -97,6 +111,9 @@ class MentraLive : SGCManager() {
97
111
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
98
112
  private const val LOUDNESS_GATE_SWITCH_TYPE = 10
99
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
100
117
 
101
118
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
102
119
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
@@ -140,6 +157,287 @@ class MentraLive : SGCManager() {
140
157
  10000 // 10 seconds for reconnection scans (faster than 60s default)
141
158
  private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
142
159
  10_000L // 10s before first scan after shutdown
160
+
161
+ private const val MENTRA_MANUFACTURER_ID = 0xB822
162
+ private const val ADV_MANUF_PAIRING_FLAG_OFFSET = 5
163
+ private const val ADV_PAIRING_DISCOVERABLE: Byte = 0x01
164
+ private const val ADV_PAIRING_PROTOCOL_VERSION_MIN = 1
165
+ private const val ADV_PAIRING_PROTOCOL_VERSION_MAX = 15
166
+
167
+ private fun manufacturerData(result: ScanResult): ByteArray? {
168
+ return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
169
+ }
170
+
171
+ /**
172
+ * OS-1615 ads append `flag | version | capability | code_lo | code_hi` after the
173
+ * connected byte. Field firmware uses the same 0xB822 company id but then writes the
174
+ * XOR'd Classic MAC at those offsets. Length alone is not a pairing flag: treating MAC
175
+ * bytes as version/capability hides every old unit as "secure, not in pairing mode".
176
+ */
177
+ private fun hasSecurePairingTrailer(manufData: ByteArray?): Boolean {
178
+ if (manufData == null) {
179
+ return false
180
+ }
181
+ val trailerBase = ADV_MANUF_PAIRING_FLAG_OFFSET + 1
182
+ if (manufData.size < trailerBase + 4) {
183
+ return false
184
+ }
185
+ val version = manufData[trailerBase].toInt() and 0xFF
186
+ val capability = manufData[trailerBase + 1].toInt() and 0xFF
187
+ return version in ADV_PAIRING_PROTOCOL_VERSION_MIN..ADV_PAIRING_PROTOCOL_VERSION_MAX &&
188
+ (capability and 0x01) != 0
189
+ }
190
+
191
+ private fun isPairingDiscoverable(result: ScanResult): Boolean {
192
+ val manufData = manufacturerData(result) ?: return false
193
+ if (!hasSecurePairingTrailer(manufData)) {
194
+ return false
195
+ }
196
+ return manufData[ADV_MANUF_PAIRING_FLAG_OFFSET] == ADV_PAIRING_DISCOVERABLE
197
+ }
198
+
199
+ private fun advertisesPairingFlag(result: ScanResult): Boolean {
200
+ return hasSecurePairingTrailer(manufacturerData(result))
201
+ }
202
+
203
+ /** Trailer immediately after pairing flag: version | capability | code_lo | code_hi */
204
+ private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
205
+ val manufData = manufacturerData(result) ?: return Triple(false, null, false)
206
+ if (!hasSecurePairingTrailer(manufData)) {
207
+ return Triple(false, null, false)
208
+ }
209
+ val pairingMode = manufData[ADV_MANUF_PAIRING_FLAG_OFFSET] == ADV_PAIRING_DISCOVERABLE
210
+ val trailerBase = ADV_MANUF_PAIRING_FLAG_OFFSET + 1
211
+ val codeLo = manufData[trailerBase + 2].toInt() and 0xFF
212
+ val codeHi = manufData[trailerBase + 3].toInt() and 0xFF
213
+ val code = String.format("%02X%02X", codeHi, codeLo)
214
+ return Triple(pairingMode, code, true)
215
+ }
216
+
217
+ fun isMentraLiveBluetoothName(name: String?): Boolean {
218
+ if (name.isNullOrEmpty()) {
219
+ return false
220
+ }
221
+ return name == "Xy_A" ||
222
+ name.startsWith("XyBLE_") ||
223
+ name.startsWith("MENTRA_LIVE_BLE") ||
224
+ name.startsWith("MENTRA_LIVE_BT") ||
225
+ name.lowercase().startsWith("mentra_live")
226
+ }
227
+
228
+ private fun safeBondedDeviceName(device: BluetoothDevice): String {
229
+ return try {
230
+ device.name ?: ""
231
+ } catch (_: SecurityException) {
232
+ ""
233
+ } catch (_: Exception) {
234
+ ""
235
+ }
236
+ }
237
+
238
+ private fun removeBondStandalone(device: BluetoothDevice): Boolean {
239
+ return try {
240
+ val method = device.javaClass.getMethod("removeBond")
241
+ val result = method.invoke(device) as Boolean
242
+ Bridge.log(
243
+ "LIVE: CTKD: removeBond " +
244
+ safeBondedDeviceName(device) +
245
+ " (" +
246
+ device.address +
247
+ ") result=" +
248
+ result
249
+ )
250
+ result
251
+ } catch (e: Exception) {
252
+ Bridge.log("LIVE: CTKD: removeBond error: " + e.message)
253
+ false
254
+ }
255
+ }
256
+
257
+ private fun invokeHiddenProfileDisconnectStandalone(
258
+ proxy: BluetoothProfile,
259
+ device: BluetoothDevice,
260
+ proxyClass: Class<*>,
261
+ label: String
262
+ ) {
263
+ try {
264
+ val state = proxy.getConnectionState(device)
265
+ if (state == BluetoothProfile.STATE_CONNECTED ||
266
+ state == BluetoothProfile.STATE_CONNECTING
267
+ ) {
268
+ val disconnectMethod =
269
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
270
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
271
+ Bridge.log(
272
+ "LIVE: Classic: " +
273
+ label +
274
+ " disconnect " +
275
+ device.address +
276
+ " result=" +
277
+ result
278
+ )
279
+ }
280
+ } catch (e: Exception) {
281
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
282
+ }
283
+ }
284
+
285
+ private fun requestProfileDisconnectStandalone(
286
+ context: Context,
287
+ adapter: BluetoothAdapter,
288
+ profile: Int,
289
+ device: BluetoothDevice,
290
+ proxyClass: Class<*>,
291
+ label: String
292
+ ) {
293
+ try {
294
+ adapter.getProfileProxy(
295
+ context,
296
+ object : BluetoothProfile.ServiceListener {
297
+ override fun onServiceConnected(
298
+ profileType: Int,
299
+ proxy: BluetoothProfile
300
+ ) {
301
+ if (profileType != profile) {
302
+ return
303
+ }
304
+ try {
305
+ invokeHiddenProfileDisconnectStandalone(
306
+ proxy,
307
+ device,
308
+ proxyClass,
309
+ label
310
+ )
311
+ } finally {
312
+ try {
313
+ adapter.closeProfileProxy(profile, proxy)
314
+ } catch (_: Exception) {
315
+ }
316
+ }
317
+ }
318
+
319
+ override fun onServiceDisconnected(profileType: Int) {}
320
+ },
321
+ profile
322
+ )
323
+ } catch (e: Exception) {
324
+ Bridge.log("LIVE: Classic: request " + label + " proxy failed: " + e.message)
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Drop A2DP/HFP + removeBond for every bonded Mentra Live unit. Safe when no SGC
330
+ * instance exists — [DeviceManager.forget] often runs after disconnect() nulled sgc
331
+ * (abandonAttempt), which previously skipped MentraLive.forget() entirely and left
332
+ * Classic ACL up so glasses never re-advertised.
333
+ */
334
+ @JvmStatic
335
+ @JvmOverloads
336
+ fun unbondBondedMentraLiveDevices(context: Context?, extraAddress: String? = null) {
337
+ if (context == null) {
338
+ Bridge.log("LIVE: CTKD: unbondBondedMentraLiveDevices — no context")
339
+ return
340
+ }
341
+ val adapter =
342
+ try {
343
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
344
+ ?.adapter
345
+ } catch (e: Exception) {
346
+ Bridge.log("LIVE: CTKD: unbond — adapter error: " + e.message)
347
+ null
348
+ }
349
+ if (adapter == null) {
350
+ Bridge.log("LIVE: CTKD: unbond — BluetoothAdapter unavailable")
351
+ return
352
+ }
353
+ val bonded =
354
+ try {
355
+ adapter.bondedDevices?.toList().orEmpty()
356
+ } catch (e: Exception) {
357
+ Bridge.log("LIVE: CTKD: unbond — cannot list bonded: " + e.message)
358
+ emptyList()
359
+ }
360
+ val extra = extraAddress?.uppercase()
361
+ val targets =
362
+ bonded.filter {
363
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
364
+ (extra != null && it.address.equals(extra, ignoreCase = true))
365
+ }
366
+ if (targets.isEmpty()) {
367
+ Bridge.log("LIVE: CTKD: unbond — no bonded Mentra Live devices")
368
+ return
369
+ }
370
+ for (device in targets) {
371
+ Bridge.log(
372
+ "LIVE: CTKD: tearing down bonded Mentra Live " +
373
+ safeBondedDeviceName(device) +
374
+ " (" +
375
+ device.address +
376
+ ")"
377
+ )
378
+ requestProfileDisconnectStandalone(
379
+ context,
380
+ adapter,
381
+ BluetoothProfile.A2DP,
382
+ device,
383
+ BluetoothA2dp::class.java,
384
+ "A2DP"
385
+ )
386
+ requestProfileDisconnectStandalone(
387
+ context,
388
+ adapter,
389
+ BluetoothProfile.HEADSET,
390
+ device,
391
+ BluetoothHeadset::class.java,
392
+ "HFP"
393
+ )
394
+ removeBondStandalone(device)
395
+ }
396
+ Handler(Looper.getMainLooper())
397
+ .postDelayed({ retryUnbondIfStillPaired(context, extraAddress) }, 700)
398
+ }
399
+
400
+ private fun retryUnbondIfStillPaired(context: Context?, extraAddress: String?) {
401
+ if (context == null) {
402
+ return
403
+ }
404
+ val adapter =
405
+ try {
406
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
407
+ ?.adapter
408
+ } catch (_: Exception) {
409
+ null
410
+ }
411
+ ?: return
412
+ val extra = extraAddress?.uppercase()
413
+ val leftover =
414
+ try {
415
+ adapter.bondedDevices?.toList().orEmpty()
416
+ } catch (_: Exception) {
417
+ emptyList()
418
+ }
419
+ .filter {
420
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
421
+ (extra != null &&
422
+ it.address.equals(extra, ignoreCase = true))
423
+ }
424
+ if (leftover.isEmpty()) {
425
+ Bridge.log("LIVE: CTKD: unbond verify — no Mentra Live bonds remain")
426
+ return
427
+ }
428
+ for (device in leftover) {
429
+ Bridge.log(
430
+ "LIVE: CTKD: unbond retry still paired " +
431
+ safeBondedDeviceName(device) +
432
+ " (" +
433
+ device.address +
434
+ ") bondState=" +
435
+ device.bondState
436
+ )
437
+ removeBondStandalone(device)
438
+ }
439
+ }
440
+
143
441
  private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
144
442
 
145
443
  // Keep-alive parameters
@@ -251,6 +549,16 @@ class MentraLive : SGCManager() {
251
549
 
252
550
  private var reconnectAttempts = 0
253
551
  private var isReconnecting = false // Track if we're in reconnection mode
552
+ // Manual discovery/connect invalidates delayed reconnect work from an earlier GATT failure.
553
+ private var reconnectGeneration = 0
554
+ // Pair Glasses discovery is an explicit no-reconnect zone. It ends only when
555
+ // the user selects a scan result (connectById) or the manager is torn down.
556
+ private var manualDiscoveryActive = false
557
+ // Set by connectById() so a post-disconnect name scan actually GATT-connects.
558
+ // Discovery-only scans (findCompatibleDevices) leave this false so a leftover
559
+ // savedDeviceName cannot auto-connect a spoofed advertiser (iOS parity: iOS
560
+ // connects on saved-name match because connectById writes that name first).
561
+ private var explicitConnectByName = false
254
562
  /**
255
563
  * Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
256
564
  * so glasses can reboot.
@@ -274,18 +582,35 @@ class MentraLive : SGCManager() {
274
582
  private val fileProcessingHandler = Handler(fileProcessingThread.looper)
275
583
  private var scheduler: ScheduledExecutorService? = null
276
584
  private var isScanning = false
585
+ // Prevent an old scan timeout from stopping a newer manual scan.
586
+ private var scanGeneration = 0
277
587
  private var isConnecting = false
278
588
  private var isKilled = false
279
589
 
590
+ /** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
591
+ private var pairingYieldActive = false
592
+ private var pairingYieldEndRunnable: Runnable? = null
593
+ /** After yield ends, auth/not-owner failures may clear saved glasses. */
594
+ private var pairingYieldAwaitingReclaim = false
595
+ /** Hold GATT teardown until the unpair write can leave the phone. */
596
+ private var unpairFlushPending = false
597
+ private var unpairFlushRunnable: Runnable? = null
598
+
280
599
  // CTKD (Cross-Transport Key Derivation) support for BES devices
281
600
  private var isBondingReceiverRegistered = false
282
601
  private var isBtClassicConnected = false
283
602
  private var bondingReceiver: BroadcastReceiver? = null
284
603
  private var bondingRetryCount = 0
285
604
 
605
+ // Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
606
+ private var pairingTimingActive = false
607
+ private var pairingTimingStartMs = 0L
608
+ private var pairingTimingLastMs = 0L
609
+
286
610
  // A2DP profile connection for already-bonded devices
287
611
  private var a2dpProfile: BluetoothA2dp? = null
288
612
  private var isA2dpProxyRegistered = false
613
+ private var a2dpConnectAttempts = 0
289
614
 
290
615
  private data class OutgoingBleCommandTraceInfo(
291
616
  val commandType: String,
@@ -310,7 +635,11 @@ class MentraLive : SGCManager() {
310
635
  val queuedAtMs: Long
311
636
  )
312
637
 
313
- private data class QueuedBleWrite(val data: ByteArray, val trace: BleWriteTrace?)
638
+ private data class QueuedBleWrite(
639
+ val data: ByteArray,
640
+ val trace: BleWriteTrace?,
641
+ val sessionGeneration: Long
642
+ )
314
643
 
315
644
  private var sendQueue = ConcurrentLinkedQueue<QueuedBleWrite>()
316
645
  private val bleWriteTraceSequence = AtomicLong(1)
@@ -603,6 +932,9 @@ class MentraLive : SGCManager() {
603
932
  // Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
604
933
  private var glassesReadyReceived = false
605
934
  private var audioConnected = false
935
+ // CTKD/A2DP must wait until GATT notifications are armed; starting Classic while the
936
+ // BLE link is still discovering services commonly yields GATT status 19 on Samsung.
937
+ private var ctkdInitiatedThisGattSession = false
606
938
 
607
939
  // Micbeat tracking - periodically enable custom audio TX
608
940
  private var micBeatHandler = Handler(Looper.getMainLooper())
@@ -612,12 +944,12 @@ class MentraLive : SGCManager() {
612
944
  // Message tracking for reliable delivery
613
945
  private val pendingMessages = ConcurrentHashMap<Long, PendingMessage>()
614
946
  private val messageIdCounter = AtomicLong(1)
947
+ private val bleSessionGeneration = AtomicLong(0)
615
948
 
616
949
  // Esoteric message ID generation
617
950
  private val secureRandom = SecureRandom()
618
951
  private val deviceId = System.currentTimeMillis() xor Random().nextLong()
619
952
 
620
- private var lastReceivedLc3Sequence: Byte = -1
621
953
  private var lc3SequenceNumber: Byte = 0
622
954
  private var lc3DecoderPtr = 0L
623
955
  private var lc3AudioPlayer: Lc3Player? = null
@@ -637,7 +969,8 @@ class MentraLive : SGCManager() {
637
969
  val messageData: String,
638
970
  val timestamp: Long,
639
971
  val retryCount: Int,
640
- val retryRunnable: Runnable
972
+ val retryRunnable: Runnable,
973
+ val sessionGeneration: Long
641
974
  )
642
975
 
643
976
  private var lc3AudioFileStream: FileOutputStream? = null
@@ -803,10 +1136,14 @@ class MentraLive : SGCManager() {
803
1136
  }
804
1137
 
805
1138
  private fun updateConnectionState(state: String) {
1139
+ if (state == ConnTypes.DISCONNECTED) {
1140
+ DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
1141
+ }
806
1142
  val isEqual = state == connectionState
807
1143
  if (isEqual) {
808
1144
  if (state == ConnTypes.DISCONNECTED) {
809
1145
  resetWireNegotiationState()
1146
+ resetPendingAckState()
810
1147
  // Queued writes are session-bound: transmitting them into the NEXT session
811
1148
  // (e.g. a stale handshake whose reply activates v2 before the new session
812
1149
  // negotiated) is the bug class this clear removes. Higher layers re-send
@@ -824,6 +1161,7 @@ class MentraLive : SGCManager() {
824
1161
  // same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
825
1162
  DeviceStore.apply("glasses", "serialNumber", "")
826
1163
  DeviceStore.apply("glasses", "bluetoothMacAddress", "")
1164
+ DeviceStore.apply("glasses", "wifiMacAddress", "")
827
1165
  }
828
1166
 
829
1167
  // Actually update the connection state!
@@ -854,6 +1192,7 @@ class MentraLive : SGCManager() {
854
1192
  DeviceStore.apply("glasses", "signalStrength", -1)
855
1193
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
856
1194
  resetWireNegotiationState()
1195
+ resetPendingAckState()
857
1196
  sendQueue.clear() // see the disconnect reset above: stale writes die with the session
858
1197
 
859
1198
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
@@ -874,6 +1213,17 @@ class MentraLive : SGCManager() {
874
1213
  cachedOtaCurrentStep = 0
875
1214
  cachedOtaStepSequence = null
876
1215
  lastBesOtaProgress = -1
1216
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
1217
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
1218
+ }
1219
+
1220
+ private fun updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
1221
+ besOtaHeartbeatGuard.observeOtaStatus(
1222
+ stepType,
1223
+ phase,
1224
+ status,
1225
+ SystemClock.elapsedRealtime(),
1226
+ )
877
1227
  }
878
1228
 
879
1229
  protected fun setFontSizes() {
@@ -884,6 +1234,10 @@ class MentraLive : SGCManager() {
884
1234
 
885
1235
  /** Starts BLE scanning for Mentra Live glasses */
886
1236
  private fun startScan() {
1237
+ if (pairingYieldActive) {
1238
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
1239
+ return
1240
+ }
887
1241
  if (bluetoothAdapter == null || isScanning) {
888
1242
  return
889
1243
  }
@@ -942,13 +1296,22 @@ class MentraLive : SGCManager() {
942
1296
  )
943
1297
  }
944
1298
 
1299
+ val generation = ++scanGeneration
945
1300
  isScanning = true
946
1301
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
1302
+ // Connected glasses stop advertising (BLE_CONNECTION_MAX=1). Pairing scan
1303
+ // would otherwise return empty even though GATT is already up.
1304
+ if (!isReconnecting) {
1305
+ handler.post { emitConnectedDeviceForPairingScan() }
1306
+ }
947
1307
 
948
1308
  // Set a timeout to stop scanning
949
1309
  handler.postDelayed(
950
1310
  object : Runnable {
951
1311
  override fun run() {
1312
+ if (generation != scanGeneration) {
1313
+ return
1314
+ }
952
1315
  if (isScanning) {
953
1316
  stopScan()
954
1317
  emitStopScanEvent()
@@ -992,11 +1355,13 @@ class MentraLive : SGCManager() {
992
1355
 
993
1356
  /** Stops BLE scanning */
994
1357
  override fun stopScan() {
1358
+ manualDiscoveryActive = false
995
1359
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
996
1360
  return
997
1361
  }
998
1362
 
999
1363
  try {
1364
+ scanGeneration++
1000
1365
  bluetoothScanner!!.stopScan(scanCallback)
1001
1366
  isScanning = false
1002
1367
  DeviceStore.apply("bluetooth", "searching", false)
@@ -1020,6 +1385,33 @@ class MentraLive : SGCManager() {
1020
1385
  Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
1021
1386
  }
1022
1387
 
1388
+ /**
1389
+ * Pairing scan listens for advertisements. A unit that is already GATT-connected
1390
+ * (typical after a CTKD retry / back-out on field firmware) has stopped ADV, so
1391
+ * emit it as pairable or the scan list stays empty.
1392
+ */
1393
+ private fun emitConnectedDeviceForPairingScan() {
1394
+ if (!isConnected || isReconnecting || pairingYieldActive) {
1395
+ return
1396
+ }
1397
+ val device = connectedDevice ?: bluetoothGatt?.device ?: return
1398
+ val name = safeBondedDeviceName(device)
1399
+ if (!isMentraLiveBluetoothName(name)) {
1400
+ return
1401
+ }
1402
+ Bridge.log(
1403
+ "LIVE: Pairing scan: already GATT-connected to $name — emitting as pairable (ADV off while connected)"
1404
+ )
1405
+ Bridge.sendDiscoveredDevice(
1406
+ DeviceTypes.LIVE,
1407
+ name,
1408
+ device.address ?: "",
1409
+ pairingMode = true,
1410
+ pairingCode = null,
1411
+ securePairingCapable = false,
1412
+ )
1413
+ }
1414
+
1023
1415
  var seenDevices: MutableSet<String> = HashSet()
1024
1416
 
1025
1417
  /** BLE Scan callback */
@@ -1072,42 +1464,77 @@ class MentraLive : SGCManager() {
1072
1464
  deviceName.startsWith("MENTRA_LIVE_BT") ||
1073
1465
  deviceName.lowercase().startsWith("mentra_live")
1074
1466
  ) {
1467
+ // Connect-by-name only when reconnecting OR connectById() asked for
1468
+ // this name. A discovery-only pairing scan must not auto-connect just
1469
+ // because savedDeviceName still matches (spoof risk).
1470
+ val isNamedConnectTarget =
1471
+ savedDeviceName.isNotEmpty() &&
1472
+ savedDeviceName == deviceName &&
1473
+ (isReconnecting || explicitConnectByName)
1474
+ if (!isReconnecting &&
1475
+ advertisesPairingFlag(result) &&
1476
+ !isPairingDiscoverable(result)
1477
+ ) {
1478
+ // Tell RN a nearby unit is advertising but not pairable so the
1479
+ // empty-state hint can fire. The scan list never shows these.
1480
+ Bridge.log(
1481
+ "LIVE: Nearby $deviceName is secure firmware not in pairing mode — hiding from scan list"
1482
+ )
1483
+ Bridge.sendDiscoveredDevice(
1484
+ DeviceTypes.LIVE,
1485
+ deviceName,
1486
+ deviceAddress,
1487
+ result.rssi,
1488
+ pairingMode = false,
1489
+ pairingCode = null,
1490
+ securePairingCapable = true,
1491
+ )
1492
+ return
1493
+ }
1075
1494
  val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
1495
+ val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
1076
1496
  Bridge.log(
1077
1497
  "LIVE: Found compatible " +
1078
1498
  glassType +
1079
1499
  " glasses device: " +
1080
- deviceName
1500
+ deviceName +
1501
+ if (secureCapable) " (pairingMode=$pairingMode)"
1502
+ else " (legacy firmware, pairable)"
1081
1503
  )
1082
- // EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
1083
- // smartGlassesDevice.deviceModelName, deviceName));
1084
1504
  Bridge.sendDiscoveredDevice(
1085
1505
  DeviceTypes.LIVE,
1086
1506
  deviceName,
1087
1507
  deviceAddress,
1088
- result.rssi
1508
+ result.rssi,
1509
+ // Field firmware has no pairing flag. Treat it as pairable so the
1510
+ // new Mentra App can still find old glasses.
1511
+ pairingMode = if (secureCapable) pairingMode else true,
1512
+ pairingCode = pairingCode,
1513
+ securePairingCapable = secureCapable,
1089
1514
  )
1090
1515
 
1091
- // If this is the specific device we want to connect to by name, connect to
1092
- // it
1093
- if (savedDeviceName != null && savedDeviceName == deviceName) {
1516
+ // connectById / reconnect: user (or reconnect) asked for this name
1517
+ // GATT-connect. Discovery-only scans never set explicitConnectByName.
1518
+ if (isNamedConnectTarget) {
1094
1519
  Log.i(
1095
1520
  TAG,
1096
- "🔌 🎯 RECONNECT TARGET FOUND - Device: " +
1521
+ "🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
1097
1522
  deviceName +
1098
1523
  " (Attempt #" +
1099
1524
  reconnectAttempts +
1100
1525
  ")"
1101
1526
  )
1102
1527
  Bridge.log(
1103
- "LIVE: 🔌 🎯 Found our remembered device by name, connecting: " +
1528
+ "LIVE: 🔌 🎯 Found target device by name, connecting: " +
1104
1529
  deviceName +
1105
- " (Reconnect attempt #" +
1106
- reconnectAttempts +
1107
- ")"
1530
+ (if (isReconnecting)
1531
+ " (Reconnect attempt #" +
1532
+ reconnectAttempts +
1533
+ ")"
1534
+ else " (connectById)")
1108
1535
  )
1109
1536
  synchronized(connectionLock) {
1110
- if (isConnected || isConnecting) {
1537
+ if (pairingYieldActive || isConnected || isConnecting) {
1111
1538
  return
1112
1539
  }
1113
1540
  isConnecting = true
@@ -1115,6 +1542,7 @@ class MentraLive : SGCManager() {
1115
1542
  stopScan()
1116
1543
  emitStopScanEvent()
1117
1544
  isReconnecting = false
1545
+ explicitConnectByName = false
1118
1546
  connectToDevice(device)
1119
1547
  }
1120
1548
  }
@@ -1176,11 +1604,67 @@ class MentraLive : SGCManager() {
1176
1604
  }
1177
1605
  }
1178
1606
 
1607
+ /**
1608
+ * Reject callbacks delivered by a GATT object from an earlier connection attempt. Without
1609
+ * this identity check, a late disconnect can tear down the active connection and clear its
1610
+ * session-bound send state.
1611
+ */
1612
+ private fun isCurrentGattCallback(gatt: BluetoothGatt): Boolean {
1613
+ if (gatt === bluetoothGatt) {
1614
+ return true
1615
+ }
1616
+
1617
+ Log.w(TAG, "🔌 Ignoring callback from stale BluetoothGatt")
1618
+ Bridge.log("LIVE: 🔌 Ignoring callback from stale BluetoothGatt session")
1619
+ try {
1620
+ gatt.close()
1621
+ } catch (e: Exception) {
1622
+ Log.w(TAG, "🔌 Failed to close stale BluetoothGatt: " + e)
1623
+ }
1624
+ return false
1625
+ }
1626
+
1627
+ private fun beginPairingTiming(reason: String) {
1628
+ pairingTimingStartMs = SystemClock.elapsedRealtime()
1629
+ pairingTimingLastMs = pairingTimingStartMs
1630
+ pairingTimingActive = true
1631
+ Bridge.log("LIVE: PAIRING_TIMING begin reason=$reason sinceStartMs=0 deltaMs=0")
1632
+ }
1633
+
1634
+ private fun markPairingTiming(checkpoint: String, detail: String = "") {
1635
+ if (!pairingTimingActive) {
1636
+ return
1637
+ }
1638
+ val now = SystemClock.elapsedRealtime()
1639
+ val sinceStart = now - pairingTimingStartMs
1640
+ val delta = now - pairingTimingLastMs
1641
+ pairingTimingLastMs = now
1642
+ val extra = if (detail.isEmpty()) "" else " $detail"
1643
+ Bridge.log(
1644
+ "LIVE: PAIRING_TIMING checkpoint=$checkpoint sinceStartMs=$sinceStart deltaMs=$delta$extra"
1645
+ )
1646
+ }
1647
+
1648
+ private fun endPairingTiming(checkpoint: String, detail: String = "") {
1649
+ markPairingTiming(checkpoint, detail)
1650
+ pairingTimingActive = false
1651
+ }
1652
+
1179
1653
  /** Connect to a specific BLE device */
1180
1654
  private fun connectToDevice(device: BluetoothDevice?) {
1181
1655
  if (device == null) {
1182
1656
  return
1183
1657
  }
1658
+ if (pairingYieldActive) {
1659
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1660
+ isConnecting = false
1661
+ isReconnecting = false
1662
+ return
1663
+ }
1664
+
1665
+ beginPairingTiming(
1666
+ "connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
1667
+ )
1184
1668
 
1185
1669
  // Cancel any previous connection timeouts
1186
1670
  if (connectionTimeoutRunnable != null) {
@@ -1280,6 +1764,85 @@ class MentraLive : SGCManager() {
1280
1764
  // }
1281
1765
  // }
1282
1766
 
1767
+ private fun enterPairingYield(windowMs: Long) {
1768
+ pairingYieldActive = true
1769
+ pairingYieldAwaitingReclaim = false
1770
+ isReconnecting = false
1771
+ isConnecting = false
1772
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1773
+ if (connectionTimeoutRunnable != null) {
1774
+ connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
1775
+ connectionTimeoutRunnable = null
1776
+ }
1777
+ // Stop scans/reconnect work; keep default_wearable / Classic bond.
1778
+ if (isScanning) {
1779
+ stopScan()
1780
+ emitStopScanEvent()
1781
+ }
1782
+ // Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
1783
+ // Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
1784
+ val yieldingDevice = connectedDevice
1785
+ disconnectClassicProfiles(yieldingDevice)
1786
+ // closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
1787
+ // onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
1788
+ // Mirror a real disconnect for UI + session state, then tear GATT down.
1789
+ isConnected = false
1790
+ connectedDevice = null
1791
+ glassesReady = false
1792
+ glassesSessionId = null
1793
+ readinessCompletedThisBleSession = false
1794
+ glassesReadyReceived = false
1795
+ ctkdInitiatedThisGattSession = false
1796
+ audioConnected = false
1797
+ notificationsEnabled = false
1798
+ processSendQueueRunnable?.let { handler.removeCallbacks(it) }
1799
+ stopReadinessCheckLoop()
1800
+ stopHeartbeat()
1801
+ stopSignalStrengthPolling()
1802
+ stopMicBeat()
1803
+ closeL2capFileChannel()
1804
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1805
+ clearFilePacketBuffer()
1806
+ updateConnectionState(ConnTypes.DISCONNECTED)
1807
+ closeGattQuietly(true)
1808
+ // Stand down for the whole window. Probing GATT during yield races the new
1809
+ // phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
1810
+ val end =
1811
+ Runnable {
1812
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1813
+ pairingYieldActive = false
1814
+ pairingYieldAwaitingReclaim = true
1815
+ pairingYieldEndRunnable = null
1816
+ if (!isKilled && !isConnected && !isConnecting) {
1817
+ handleReconnection()
1818
+ }
1819
+ }
1820
+ pairingYieldEndRunnable = end
1821
+ handler.postDelayed(end, windowMs)
1822
+ }
1823
+
1824
+ fun isPairingYieldActive(): Boolean = pairingYieldActive
1825
+
1826
+ private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
1827
+ Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
1828
+ pairingYieldAwaitingReclaim = false
1829
+ pairingYieldActive = false
1830
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1831
+ pairingYieldEndRunnable = null
1832
+ try {
1833
+ forget()
1834
+ } catch (e: Exception) {
1835
+ Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
1836
+ }
1837
+ DeviceStore.apply("bluetooth", "default_wearable", "")
1838
+ DeviceStore.apply("bluetooth", "device_name", "")
1839
+ DeviceStore.apply("bluetooth", "device_address", "")
1840
+ Bridge.saveSetting("default_wearable", "")
1841
+ Bridge.saveSetting("device_name", "")
1842
+ Bridge.saveSetting("device_address", "")
1843
+ Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
1844
+ }
1845
+
1283
1846
  /** Handle reconnection with exponential backoff */
1284
1847
  private fun handleReconnection() {
1285
1848
  // Don't attempt reconnection if we've been killed/forgotten
@@ -1289,6 +1852,20 @@ class MentraLive : SGCManager() {
1289
1852
  return
1290
1853
  }
1291
1854
 
1855
+ if (manualDiscoveryActive) {
1856
+ Bridge.log(
1857
+ "LIVE: 🔌 RECONNECT ABORTED - Pair Glasses discovery is active"
1858
+ )
1859
+ isReconnecting = false
1860
+ return
1861
+ }
1862
+
1863
+ if (pairingYieldActive) {
1864
+ Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
1865
+ isReconnecting = false
1866
+ return
1867
+ }
1868
+
1292
1869
  if (isReconnecting) {
1293
1870
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
1294
1871
  return
@@ -1360,10 +1937,25 @@ class MentraLive : SGCManager() {
1360
1937
  "ms)"
1361
1938
  )
1362
1939
 
1363
- // Schedule reconnection attempt
1940
+ // Schedule reconnection attempt. A later manual scan/connect invalidates this
1941
+ // callback so stale saved identity cannot hijack user-initiated discovery.
1942
+ val generation = reconnectGeneration
1364
1943
  handler.postDelayed(
1365
1944
  object : Runnable {
1366
1945
  override fun run() {
1946
+ if (generation != reconnectGeneration) {
1947
+ Bridge.log(
1948
+ "LIVE: 🔌 RECONNECT ABORTED - superseded by manual discovery"
1949
+ )
1950
+ return
1951
+ }
1952
+ if (pairingYieldActive) {
1953
+ isReconnecting = false
1954
+ Bridge.log(
1955
+ "LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
1956
+ )
1957
+ return
1958
+ }
1367
1959
  if (!isConnected && !isConnecting && !isKilled) {
1368
1960
  // Prefer saved MAC for direct GATT connect (faster and more reliable
1369
1961
  // than scanning).
@@ -1498,6 +2090,10 @@ class MentraLive : SGCManager() {
1498
2090
  status: Int,
1499
2091
  newState: Int
1500
2092
  ) {
2093
+ if (!isCurrentGattCallback(gatt)) {
2094
+ return
2095
+ }
2096
+
1501
2097
  // Cancel the connection timeout
1502
2098
  if (connectionTimeoutRunnable != null) {
1503
2099
  connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
@@ -1509,6 +2105,7 @@ class MentraLive : SGCManager() {
1509
2105
  Bridge.log(
1510
2106
  "LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
1511
2107
  )
2108
+ markPairingTiming("gatt_connected")
1512
2109
  isConnecting = false
1513
2110
  isConnected = true
1514
2111
  connectedDevice = gatt.device
@@ -1536,42 +2133,27 @@ class MentraLive : SGCManager() {
1536
2133
  // connectedDevice.getName());
1537
2134
  // }
1538
2135
 
1539
- // CTKD Implementation: Register bonding receiver and create bond for BT
1540
- // Classic
2136
+ // Register early so we don't miss bond broadcasts, but defer
2137
+ // createBond / A2DP until BLE GATT setup finishes (see
2138
+ // maybeInitiateCtkdAudioAfterBleReady).
1541
2139
  registerBondingReceiver()
1542
- bondingRetryCount = 0 // Reset retry counter for new connection
2140
+ bondingRetryCount = 0
2141
+ ctkdInitiatedThisGattSession = false
1543
2142
  Bridge.log(
1544
- "LIVE: CTKD: BLE connection established, initiating CTKD bonding for BT Classic"
2143
+ "LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
2144
+ )
2145
+ markPairingTiming(
2146
+ "ctkd_deferred",
2147
+ "bondState=${connectedDevice!!.bondState}"
1545
2148
  )
1546
-
1547
- // Check if device is already bonded before attempting to create bond
1548
- if (connectedDevice!!.bondState == BluetoothDevice.BOND_BONDED) {
1549
- Bridge.log(
1550
- "LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
1551
- )
1552
- // Device is bonded but we need to explicitly connect the A2DP audio
1553
- // profile
1554
- // Just being bonded doesn't mean the audio profile is connected
1555
- connectA2dpProfile(connectedDevice!!)
1556
- // Note: audioConnected will be set to true once A2DP profile
1557
- // connects
1558
- } else {
1559
- createBond(connectedDevice!!)
1560
- }
1561
2149
 
1562
2150
  // Discover services
1563
2151
  gatt.discoverServices()
1564
2152
 
1565
- // Reset reconnect attempts on successful connection
1566
- val previousAttempts = reconnectAttempts
1567
- reconnectAttempts = 0
1568
- isReconnecting = false // Clear reconnection mode
1569
- Log.i(
1570
- TAG,
1571
- "🔌 ✅ Reconnection counter reset (was at " +
1572
- previousAttempts +
1573
- " attempts)"
1574
- )
2153
+ // Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
2154
+ // followed by status 19 was zeroing the counter every ~1s and
2155
+ // preventing exponential backoff. Reset at ble_chars_ready instead.
2156
+ isReconnecting = false
1575
2157
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1576
2158
  Log.w(
1577
2159
  TAG,
@@ -1580,6 +2162,10 @@ class MentraLive : SGCManager() {
1580
2162
  Bridge.log(
1581
2163
  "LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
1582
2164
  )
2165
+ endPairingTiming(
2166
+ "gatt_disconnected",
2167
+ "queueSize=${sendQueue.size}"
2168
+ )
1583
2169
  isConnected = false
1584
2170
  isConnecting = false
1585
2171
 
@@ -1590,6 +2176,7 @@ class MentraLive : SGCManager() {
1590
2176
 
1591
2177
  // Reset audio pairing flags
1592
2178
  glassesReadyReceived = false
2179
+ ctkdInitiatedThisGattSession = false
1593
2180
  audioConnected = false
1594
2181
 
1595
2182
  notificationsEnabled = false
@@ -1648,6 +2235,10 @@ class MentraLive : SGCManager() {
1648
2235
  status +
1649
2236
  ") - Will retry reconnection"
1650
2237
  )
2238
+ endPairingTiming(
2239
+ "gatt_error",
2240
+ "status=$status queueSize=${sendQueue.size}"
2241
+ )
1651
2242
  isConnected = false
1652
2243
  isConnecting = false
1653
2244
  glassesReady = false
@@ -1655,6 +2246,7 @@ class MentraLive : SGCManager() {
1655
2246
  readinessCompletedThisBleSession = false
1656
2247
  glassesReadyReceived = false
1657
2248
  audioConnected = false
2249
+ ctkdInitiatedThisGattSession = false
1658
2250
 
1659
2251
  notificationsEnabled = false
1660
2252
 
@@ -1678,6 +2270,19 @@ class MentraLive : SGCManager() {
1678
2270
  // Clean up resources
1679
2271
  closeGattQuietly(false)
1680
2272
 
2273
+ // Auth/not-owner after pairing yield — not a generic RF blip.
2274
+ // GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
2275
+ // not wipe the saved owner.
2276
+ if (pairingYieldAwaitingReclaim &&
2277
+ (status == 5 ||
2278
+ status == 15 ||
2279
+ status == 0x05 ||
2280
+ status == 0x0F)
2281
+ ) {
2282
+ clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
2283
+ return
2284
+ }
2285
+
1681
2286
  // Attempt reconnection if not killed
1682
2287
  if (!isKilled) {
1683
2288
  Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
@@ -1728,24 +2333,47 @@ class MentraLive : SGCManager() {
1728
2333
  txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
1729
2334
  rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
1730
2335
 
1731
- // Get LC3 characteristics (always supported)
2336
+ // LC3 audio is optional. Older firmware can still run the core session
2337
+ // and receive an OTA without these characteristics.
1732
2338
  lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
1733
2339
  lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
1734
2340
 
1735
- // Check if we have required characteristics
2341
+ // Match iOS: only the core transport pair is required for readiness.
1736
2342
  val hasRequiredCharacteristics =
1737
- (rxCharacteristic != null && txCharacteristic != null) &&
1738
- (lc3ReadCharacteristic != null &&
1739
- lc3WriteCharacteristic != null)
2343
+ hasRequiredMentraLiveCoreCharacteristics(
2344
+ hasRxCharacteristic = rxCharacteristic != null,
2345
+ hasTxCharacteristic = txCharacteristic != null,
2346
+ )
2347
+ val hasLc3AudioCharacteristics =
2348
+ lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
1740
2349
 
1741
2350
  if (hasRequiredCharacteristics) {
2351
+ if (!hasLc3AudioCharacteristics) {
2352
+ Bridge.log(
2353
+ "LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
2354
+ )
2355
+ }
1742
2356
  // BLE connection established, but we still need to wait for glasses
1743
2357
  // SOC
1744
2358
  Bridge.log(
1745
- "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX + LC3 TX/RX characteristics verified)"
2359
+ "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
2360
+ hasLc3AudioCharacteristics +
2361
+ ")"
1746
2362
  )
2363
+ markPairingTiming("ble_chars_ready")
1747
2364
  Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1748
2365
 
2366
+ // GATT stack reached real readiness — safe to clear reconnect
2367
+ // backoff.
2368
+ val previousAttempts = reconnectAttempts
2369
+ reconnectAttempts = 0
2370
+ Log.i(
2371
+ TAG,
2372
+ "🔌 ✅ Reconnection counter reset at ble_chars_ready (was at " +
2373
+ previousAttempts +
2374
+ " attempts)"
2375
+ )
2376
+
1749
2377
  // Don't set connected=true here - wait for SOC to be ready
1750
2378
  // (fullyBooted=true)
1751
2379
  // DeviceStore handles connected state based on fullyBooted
@@ -1786,13 +2414,6 @@ class MentraLive : SGCManager() {
1786
2414
  if (txCharacteristic == null) {
1787
2415
  Log.e(TAG, "TX characteristic (peripheral's RX) not found")
1788
2416
  }
1789
- // Log LC3 characteristic errors
1790
- if (lc3ReadCharacteristic == null) {
1791
- Log.e(TAG, "LC3_READ characteristic not found")
1792
- }
1793
- if (lc3WriteCharacteristic == null) {
1794
- Log.e(TAG, "LC3_WRITE characteristic not found")
1795
- }
1796
2417
  gatt.disconnect()
1797
2418
  }
1798
2419
  } else {
@@ -1899,6 +2520,10 @@ class MentraLive : SGCManager() {
1899
2520
  gatt: BluetoothGatt,
1900
2521
  characteristic: BluetoothGattCharacteristic
1901
2522
  ) {
2523
+ if (!isCurrentGattCallback(gatt)) {
2524
+ return
2525
+ }
2526
+
1902
2527
  // Get thread ID for tracking thread issues
1903
2528
  val threadId = Thread.currentThread().id
1904
2529
  val uuid = characteristic.uuid
@@ -1932,20 +2557,6 @@ class MentraLive : SGCManager() {
1932
2557
 
1933
2558
  if (isRxCharacteristic) {
1934
2559
  Bridge.log("LIVE: Received data on RX characteristic")
1935
- // #region agent log [810da2] Hypothesis A+C: capture data.length vs
1936
- // negotiated MTU
1937
- Bridge.log(
1938
- "LIVE: [DEBUG-810da2-HypAC] RX dataLen=" +
1939
- data.size +
1940
- " mtu=" +
1941
- currentMtu +
1942
- " firstByte=0x" +
1943
- String.format("%02X", data[0]) +
1944
- " second=0x" +
1945
- (if (data.size > 1) String.format("%02X", data[1])
1946
- else "??")
1947
- )
1948
- // #endregion
1949
2560
  } else if (isTxCharacteristic) {
1950
2561
  Bridge.log("LIVE: Received data on TX characteristic")
1951
2562
  } else if (isLc3ReadCharacteristic) {
@@ -2050,6 +2661,9 @@ class MentraLive : SGCManager() {
2050
2661
  startSignalStrengthPolling()
2051
2662
  handler.post(processSendQueueRunnable!!)
2052
2663
  startReadinessCheckLoop()
2664
+ // Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
2665
+ // collide during service discovery / descriptor writes.
2666
+ maybeInitiateCtkdAudioAfterBleReady()
2053
2667
  return
2054
2668
  }
2055
2669
 
@@ -2310,8 +2924,20 @@ class MentraLive : SGCManager() {
2310
2924
  return
2311
2925
  }
2312
2926
 
2313
- // Send the next item from the queue
2314
- val queuedWrite = sendQueue.poll()
2927
+ // Send the next item from this BLE session. A retry can race disconnect teardown and land
2928
+ // after sendQueue.clear(); the generation check is the deterministic backstop.
2929
+ var queuedWrite = sendQueue.poll()
2930
+ val currentGeneration = bleSessionGeneration.get()
2931
+ while (queuedWrite != null && queuedWrite.sessionGeneration != currentGeneration) {
2932
+ Bridge.log(
2933
+ "LIVE: Dropping stale queued write from BLE session " +
2934
+ queuedWrite.sessionGeneration +
2935
+ " (current: " +
2936
+ currentGeneration +
2937
+ ")"
2938
+ )
2939
+ queuedWrite = sendQueue.poll()
2940
+ }
2315
2941
  if (queuedWrite != null) {
2316
2942
  // Update last send time before sending
2317
2943
  lastSendTimeMs = currentTimeMs
@@ -2338,25 +2964,35 @@ class MentraLive : SGCManager() {
2338
2964
 
2339
2965
  /** Send data through BLE */
2340
2966
  private fun sendDataInternal(write: QueuedBleWrite?) {
2341
- if (!isConnected || bluetoothGatt == null || txCharacteristic == null || write == null) {
2967
+ if (!isConnected || write == null) {
2968
+ return
2969
+ }
2970
+ if (write.sessionGeneration != bleSessionGeneration.get()) {
2971
+ Bridge.log("LIVE: Dropping stale BLE write before GATT transmission")
2972
+ return
2973
+ }
2974
+
2975
+ val gatt = bluetoothGatt
2976
+ val characteristic = txCharacteristic
2977
+ if (gatt == null || characteristic == null) {
2342
2978
  return
2343
2979
  }
2344
2980
 
2345
2981
  try {
2346
2982
  val writeStartedAtMs = System.currentTimeMillis()
2347
- txCharacteristic!!.value = write.data
2983
+ characteristic.value = write.data
2348
2984
  inFlightBleWriteTrace = write.trace
2349
2985
  inFlightBleWriteStartedAtMs = writeStartedAtMs
2350
- val writeAccepted = bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2986
+ val writeAccepted = gatt.writeCharacteristic(characteristic)
2351
2987
  logBleWriteTrace(
2352
2988
  "write_call",
2353
2989
  write.trace,
2354
2990
  mapOf(
2355
2991
  "writeAccepted" to writeAccepted,
2356
2992
  "currentMtu" to currentMtu,
2357
- "writeType" to txCharacteristic!!.writeType,
2993
+ "writeType" to characteristic.writeType,
2358
2994
  "queueSize" to sendQueue.size,
2359
- "characteristicUuid" to txCharacteristic!!.uuid.toString()
2995
+ "characteristicUuid" to characteristic.uuid.toString()
2360
2996
  )
2361
2997
  )
2362
2998
  if (!writeAccepted) {
@@ -2379,7 +3015,11 @@ class MentraLive : SGCManager() {
2379
3015
  }
2380
3016
 
2381
3017
  /** Queue data to be sent */
2382
- private fun queueData(data: ByteArray?, trace: BleWriteTrace? = null) {
3018
+ private fun queueData(
3019
+ data: ByteArray?,
3020
+ trace: BleWriteTrace? = null,
3021
+ sessionGeneration: Long = bleSessionGeneration.get()
3022
+ ) {
2383
3023
  if (data != null) {
2384
3024
  val queuedTrace =
2385
3025
  trace?.copy(
@@ -2388,7 +3028,7 @@ class MentraLive : SGCManager() {
2388
3028
  trace.queuedAtMs
2389
3029
  else System.currentTimeMillis()
2390
3030
  )
2391
- sendQueue.add(QueuedBleWrite(data, queuedTrace))
3031
+ sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
2392
3032
  logBleChunkTrace(
2393
3033
  "queued",
2394
3034
  queuedTrace,
@@ -2432,6 +3072,7 @@ class MentraLive : SGCManager() {
2432
3072
  private fun sendJson(json: JSONObject?, wakeup: Boolean) {
2433
3073
  if (json != null) {
2434
3074
  try {
3075
+ val sessionGeneration = bleSessionGeneration.get()
2435
3076
  if (buildNumberInt < 5) {
2436
3077
  val jsonStr = json.toString()
2437
3078
  // Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
@@ -2447,7 +3088,7 @@ class MentraLive : SGCManager() {
2447
3088
  json,
2448
3089
  jsonStr.length
2449
3090
  )
2450
- sendDataToGlasses(jsonStr, wakeup)
3091
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2451
3092
  } else {
2452
3093
  // Add esoteric message ID to the JSON
2453
3094
  val messageId = generateEsotericMessageId()
@@ -2489,7 +3130,7 @@ class MentraLive : SGCManager() {
2489
3130
  }
2490
3131
 
2491
3132
  // Track the message for ACK with appropriate timeout
2492
- trackMessageForAck(messageId, jsonStr, ackTimeout)
3133
+ trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
2493
3134
 
2494
3135
  // Send the data
2495
3136
  if ("take_photo" == json.optString("type", "")) {
@@ -2508,7 +3149,7 @@ class MentraLive : SGCManager() {
2508
3149
  json,
2509
3150
  jsonStr.length
2510
3151
  )
2511
- sendDataToGlasses(jsonStr, wakeup)
3152
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2512
3153
  }
2513
3154
  } catch (e: JSONException) {
2514
3155
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -2528,14 +3169,14 @@ class MentraLive : SGCManager() {
2528
3169
  return list
2529
3170
  }
2530
3171
 
2531
- /** Track a message for ACK response */
2532
- private fun trackMessageForAck(messageId: Long, messageData: String) {
2533
- trackMessageForAck(messageId, messageData, ACK_TIMEOUT_MS)
2534
- }
2535
-
2536
3172
  /** Track a message for ACK response with custom timeout */
2537
- private fun trackMessageForAck(messageId: Long, messageData: String, timeoutMs: Long) {
2538
- if (!isConnected) {
3173
+ private fun trackMessageForAck(
3174
+ messageId: Long,
3175
+ messageData: String,
3176
+ timeoutMs: Long,
3177
+ sessionGeneration: Long
3178
+ ) {
3179
+ if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
2539
3180
  Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
2540
3181
  return
2541
3182
  }
@@ -2552,25 +3193,50 @@ class MentraLive : SGCManager() {
2552
3193
  }
2553
3194
 
2554
3195
  // Create retry runnable
2555
- val retryRunnable = Runnable { retryMessage(messageId) }
3196
+ val retryRunnable = Runnable { retryMessage(messageId, sessionGeneration) }
2556
3197
 
2557
3198
  // Create pending message
2558
3199
  val pendingMessage =
2559
- PendingMessage(messageData, System.currentTimeMillis(), 0, retryRunnable)
3200
+ PendingMessage(
3201
+ messageData,
3202
+ System.currentTimeMillis(),
3203
+ 0,
3204
+ retryRunnable,
3205
+ sessionGeneration
3206
+ )
2560
3207
  pendingMessages[messageId] = pendingMessage
2561
3208
 
2562
3209
  // Schedule ACK timeout with custom timeout
2563
- handler.postDelayed({ checkMessageAck(messageId) }, timeoutMs)
3210
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
2564
3211
 
2565
3212
  Bridge.log(
2566
3213
  "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
2567
3214
  )
2568
3215
  }
2569
3216
 
3217
+ /**
3218
+ * Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
3219
+ * the old generation, and queued retries retain it, so a retry racing this clear cannot become
3220
+ * valid in the next session.
3221
+ */
3222
+ private fun resetPendingAckState() {
3223
+ val nextGeneration = bleSessionGeneration.incrementAndGet()
3224
+ val pendingCount = pendingMessages.size
3225
+ pendingMessages.clear()
3226
+ if (pendingCount > 0) {
3227
+ Bridge.log(
3228
+ "LIVE: Cleared $pendingCount pending ACK message(s); BLE session is now $nextGeneration"
3229
+ )
3230
+ }
3231
+ }
3232
+
2570
3233
  /** Check if a message has been acknowledged */
2571
- private fun checkMessageAck(messageId: Long) {
3234
+ private fun checkMessageAck(messageId: Long, sessionGeneration: Long) {
2572
3235
  val pendingMessage = pendingMessages[messageId]
2573
- if (pendingMessage != null) {
3236
+ if (pendingMessage != null &&
3237
+ pendingMessage.sessionGeneration == sessionGeneration &&
3238
+ sessionGeneration == bleSessionGeneration.get()
3239
+ ) {
2574
3240
  Log.w(
2575
3241
  TAG,
2576
3242
  "⏰ ACK timeout for message " +
@@ -2591,7 +3257,7 @@ class MentraLive : SGCManager() {
2591
3257
  MAX_RETRY_ATTEMPTS +
2592
3258
  ")"
2593
3259
  )
2594
- retryMessage(messageId)
3260
+ retryMessage(messageId, sessionGeneration)
2595
3261
  } else {
2596
3262
  // Max retries reached
2597
3263
  Log.e(
@@ -2602,22 +3268,31 @@ class MentraLive : SGCManager() {
2602
3268
  MAX_RETRY_ATTEMPTS +
2603
3269
  " attempts"
2604
3270
  )
2605
- pendingMessages.remove(messageId)
3271
+ pendingMessages.remove(messageId, pendingMessage)
2606
3272
  }
3273
+ } else if (pendingMessage != null && pendingMessage.sessionGeneration == sessionGeneration) {
3274
+ pendingMessages.remove(messageId, pendingMessage)
2607
3275
  }
2608
3276
  }
2609
3277
 
2610
3278
  /** Retry a message */
2611
- private fun retryMessage(messageId: Long) {
3279
+ private fun retryMessage(messageId: Long, sessionGeneration: Long) {
2612
3280
  val pendingMessage = pendingMessages[messageId]
2613
3281
  if (pendingMessage == null) {
2614
3282
  Log.w(TAG, "Message " + messageId + " no longer tracked for retry")
2615
3283
  return
2616
3284
  }
3285
+ if (pendingMessage.sessionGeneration != sessionGeneration ||
3286
+ sessionGeneration != bleSessionGeneration.get()
3287
+ ) {
3288
+ pendingMessages.remove(messageId, pendingMessage)
3289
+ Bridge.log("LIVE: Dropping stale ACK retry for message $messageId")
3290
+ return
3291
+ }
2617
3292
 
2618
3293
  if (pendingMessage.retryCount >= MAX_RETRY_ATTEMPTS) {
2619
3294
  Log.e(TAG, "Max retries reached for message " + messageId)
2620
- pendingMessages.remove(messageId)
3295
+ pendingMessages.remove(messageId, pendingMessage)
2621
3296
  return
2622
3297
  }
2623
3298
 
@@ -2627,11 +3302,18 @@ class MentraLive : SGCManager() {
2627
3302
  pendingMessage.messageData,
2628
3303
  System.currentTimeMillis(),
2629
3304
  pendingMessage.retryCount + 1,
2630
- pendingMessage.retryRunnable
3305
+ pendingMessage.retryRunnable,
3306
+ sessionGeneration
2631
3307
  )
2632
3308
 
2633
- // Update the tracked message
2634
- pendingMessages[messageId] = retryMessage
3309
+ // Replace only the entry inspected above. An ACK or disconnect may have removed it.
3310
+ if (!pendingMessages.replace(messageId, pendingMessage, retryMessage)) {
3311
+ return
3312
+ }
3313
+ if (sessionGeneration != bleSessionGeneration.get()) {
3314
+ pendingMessages.remove(messageId, retryMessage)
3315
+ return
3316
+ }
2635
3317
 
2636
3318
  // Send the message again
2637
3319
  Bridge.log(
@@ -2641,10 +3323,10 @@ class MentraLive : SGCManager() {
2641
3323
  retryMessage.retryCount +
2642
3324
  ")"
2643
3325
  )
2644
- sendDataToGlasses(pendingMessage.messageData, false)
3326
+ sendDataToGlasses(pendingMessage.messageData, false, sessionGeneration)
2645
3327
 
2646
3328
  // Schedule next ACK check
2647
- handler.postDelayed({ checkMessageAck(messageId) }, ACK_TIMEOUT_MS)
3329
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, ACK_TIMEOUT_MS)
2648
3330
  }
2649
3331
 
2650
3332
  /** Process ACK response from glasses */
@@ -2955,25 +3637,6 @@ class MentraLive : SGCManager() {
2955
3637
  processJsonMessage(expanded)
2956
3638
  } else {
2957
3639
  Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
2958
- // #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
2959
- // data length
2960
- val declaredPayloadLen =
2961
- if (data.size >= 5)
2962
- (((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF))
2963
- else -1
2964
- Bridge.log(
2965
- "LIVE: [DEBUG-810da2-HypAB] K900 PARSE FAILED thread=" +
2966
- threadId +
2967
- " dataLen=" +
2968
- data.size +
2969
- " mtu=" +
2970
- currentMtu +
2971
- " declaredPayloadLen=" +
2972
- declaredPayloadLen +
2973
- " expectedTotal=" +
2974
- (declaredPayloadLen + 7)
2975
- )
2976
- // #endregion
2977
3640
  }
2978
3641
 
2979
3642
  return // Exit after processing K900 protocol format
@@ -3147,6 +3810,27 @@ class MentraLive : SGCManager() {
3147
3810
  "pong" ->
3148
3811
  // Process heartbeat pong response
3149
3812
  Bridge.log("LIVE: Received pong response - connection healthy")
3813
+ "entering_pairing_mode" -> {
3814
+ val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
3815
+ Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
3816
+ enterPairingYield(windowMs)
3817
+ val body = HashMap<String, Any>()
3818
+ body["window_ms"] = windowMs
3819
+ body["reason"] = json.optString("reason", "user_gesture")
3820
+ if (json.has("txn")) body["txn"] = json.optInt("txn")
3821
+ Bridge.sendTypedMessage("entering_pairing_mode", body)
3822
+ }
3823
+ "pairing_info" ->
3824
+ Bridge.sendPairingInfo(
3825
+ json.optBoolean("had_previous_bond", false),
3826
+ jsonNullableString(json, "transfer_id"),
3827
+ jsonNullableString(json, "pairing_code"),
3828
+ json.optBoolean("classic_bond_ready", false),
3829
+ // Legacy firmware that omits this field is not secure-capable.
3830
+ json.optBoolean("secure_pairing_capable", false),
3831
+ json.optInt("protocol_version", 1),
3832
+ jsonNullableString(json, "binding"),
3833
+ )
3150
3834
  "imu_response",
3151
3835
  "imu_stream_response",
3152
3836
  "imu_gesture_response",
@@ -3328,6 +4012,7 @@ class MentraLive : SGCManager() {
3328
4012
  if (osStepSequence != null && osStepSequence.length() > 0) {
3329
4013
  cachedOtaStepSequence = osStepSequence
3330
4014
  }
4015
+ updateBesOtaHeartbeatGuard(osStepType, osPhase, osStatus)
3331
4016
 
3332
4017
  Bridge.log(
3333
4018
  "LIVE: 📱 OTA status - step " +
@@ -3378,6 +4063,7 @@ class MentraLive : SGCManager() {
3378
4063
  } else {
3379
4064
  unified = "in_progress"
3380
4065
  }
4066
+ updateBesOtaHeartbeatGuard(currentUpdate, legacyPhase, unified)
3381
4067
  Bridge.log(
3382
4068
  "LIVE: 📱 Legacy ota_progress → ota_status: " +
3383
4069
  legacyStage +
@@ -3533,7 +4219,19 @@ class MentraLive : SGCManager() {
3533
4219
  }
3534
4220
  "glasses_ready" -> {
3535
4221
  // Glasses SOC has booted and is ready for communication
4222
+ if (pairingYieldActive && !isConnected) {
4223
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
4224
+ return
4225
+ }
3536
4226
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
4227
+ pairingYieldAwaitingReclaim = false
4228
+ pairingYieldActive = false
4229
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
4230
+ pairingYieldEndRunnable = null
4231
+ markPairingTiming(
4232
+ "glasses_ready",
4233
+ "audioConnected=$audioConnected queueSize=${sendQueue.size}"
4234
+ )
3537
4235
 
3538
4236
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
3539
4237
  // onTransportReset() before sending it, so their side is back on legacy
@@ -3552,9 +4250,9 @@ class MentraLive : SGCManager() {
3552
4250
  // Set the ready flag to stop any future readiness checks
3553
4251
  glassesReady = true
3554
4252
  glassesReadyReceived = true
3555
- // NOTE: Don't set fullyBooted here - it will be set when BOTH glasses_ready
3556
- // AND audioConnected are true (see below). This ensures BT Classic pairing
3557
- // is complete before the device is considered "paired" in MentraOS.
4253
+ // Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
4254
+ // fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
4255
+ // for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
3558
4256
 
3559
4257
  // Stop the readiness check loop since we got confirmation
3560
4258
  stopReadinessCheckLoop()
@@ -3674,19 +4372,21 @@ class MentraLive : SGCManager() {
3674
4372
  Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
3675
4373
  }
3676
4374
 
3677
- // Audio Pairing: Only mark as fully connected if audio is also ready
3678
- // On Android, CTKD automatically pairs BT Classic when BLE bonds, so audio is
3679
- // always ready
3680
- // This check maintains platform parity with iOS
3681
- if (audioConnected) {
4375
+ // Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
4376
+ // Do not wait for CTKD here iOS already marks ready on glasses_ready alone.
4377
+ endPairingTiming(
4378
+ "fully_connected",
4379
+ "via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
4380
+ )
4381
+ DeviceStore.apply("glasses", "fullyBooted", true)
4382
+ updateConnectionState(ConnTypes.CONNECTED)
4383
+ if (!audioConnected) {
3682
4384
  Bridge.log(
3683
- "LIVE: Audio: Both glasses_ready and audio connected - marking as fully connected"
4385
+ "LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
3684
4386
  )
3685
- DeviceStore.apply("glasses", "fullyBooted", true)
3686
- updateConnectionState(ConnTypes.CONNECTED)
3687
- } else {
3688
- Bridge.log(
3689
- "LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"
4387
+ markPairingTiming(
4388
+ "waiting_ctkd_bond",
4389
+ "queueSize=${sendQueue.size}"
3690
4390
  )
3691
4391
  }
3692
4392
  }
@@ -4000,6 +4700,9 @@ class MentraLive : SGCManager() {
4000
4700
  (fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4001
4701
  DeviceStore.apply("glasses", "bluetoothMacAddress", it)
4002
4702
  }
4703
+ (fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4704
+ DeviceStore.apply("glasses", "wifiMacAddress", it)
4705
+ }
4003
4706
  (fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4004
4707
  DeviceStore.apply("glasses", "serialNumber", it)
4005
4708
  }
@@ -4009,6 +4712,12 @@ class MentraLive : SGCManager() {
4009
4712
  DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
4010
4713
  }
4011
4714
  }
4715
+ if (fields.containsKey("hotspot_ota_version")) {
4716
+ val version = fields["hotspot_ota_version"]
4717
+ if (version is Number) {
4718
+ DeviceStore.apply("glasses", "hotspotOtaVersion", version.toInt())
4719
+ }
4720
+ }
4012
4721
 
4013
4722
  // Negotiate wire caps from ANY version_info chunk, not only the one
4014
4723
  // carrying build_number: the glasses put wire_caps in version_info_3
@@ -4365,6 +5074,10 @@ class MentraLive : SGCManager() {
4365
5074
  }
4366
5075
  if (ready == 1) {
4367
5076
  Bridge.log("LIVE: K900 SOC ready")
5077
+ markPairingTiming(
5078
+ "soc_ready",
5079
+ "glassesReady=$glassesReady queueSize=${sendQueue.size}"
5080
+ )
4368
5081
  // Only send phone_ready if we haven't already established connection
4369
5082
  // This prevents re-initialization on every heartbeat after initial
4370
5083
  // connection
@@ -4468,15 +5181,28 @@ class MentraLive : SGCManager() {
4468
5181
  var progress = ((rawProgress + 2) / 5) * 5
4469
5182
  if (progress > 100) progress = 100
4470
5183
 
4471
- // Only send if progress changed to a new 5% increment
4472
- if (progress == lastBesOtaProgress &&
4473
- "success" != type &&
4474
- "error" != type &&
4475
- "fail" != type
4476
- ) {
4477
- return // Skip duplicate progress
5184
+ val mapping =
5185
+ mapBesOtaProgress(
5186
+ type,
5187
+ rawProgress,
5188
+ bodyObj.optString("message", "BES update failed"),
5189
+ )
5190
+ val besOtaStatus = mapping.status
5191
+ val besOtaProgressVal = mapping.progress
5192
+ val besOtaErrorMessage = mapping.errorMessage
5193
+
5194
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
5195
+ besOtaHeartbeatGuard.clear()
5196
+ } else {
5197
+ // Refresh before progress de-duplication: raw progress proves the BES
5198
+ // transfer is active even when the rounded UI value is unchanged.
5199
+ besOtaHeartbeatGuard.refresh(SystemClock.elapsedRealtime())
5200
+ }
5201
+
5202
+ // Only send if nonterminal display progress changed to a new 5% increment.
5203
+ if ("PROGRESS" == besOtaStatus && besOtaProgressVal == lastBesOtaProgress) {
5204
+ return
4478
5205
  }
4479
- lastBesOtaProgress = progress
4480
5206
 
4481
5207
  Bridge.log(
4482
5208
  "LIVE: 📱 BES OTA progress via sr_adota - type: " +
@@ -4485,58 +5211,21 @@ class MentraLive : SGCManager() {
4485
5211
  rawProgress +
4486
5212
  "%, rounded: " +
4487
5213
  progress +
5214
+ "%, display: " +
5215
+ besOtaProgressVal +
4488
5216
  "%"
4489
5217
  )
4490
5218
 
4491
- // Determine status and error message based on type
4492
- val besOtaStatus: String
4493
- val besOtaProgressVal: Int
4494
- var besOtaErrorMessage: String? = null
4495
-
4496
- // Order matters here: check completion (rawProgress >= 100 OR success)
4497
- // BEFORE
4498
- // type=="update", because some BES firmware emits the final 100% tick with
4499
- // type=="update" rather than type=="success". Treating that as PROGRESS
4500
- // would
4501
- // leave the UI stuck at 100% forever.
4502
- if ("success" == type || rawProgress >= 100) {
4503
- besOtaStatus = "FINISHED"
4504
- besOtaProgressVal = 100
5219
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
4505
5220
  lastBesOtaProgress = -1 // Reset for next OTA
4506
- } else if ("error" == type || "fail" == type) {
4507
- besOtaStatus = "FAILED"
4508
- besOtaProgressVal = progress
4509
- besOtaErrorMessage = bodyObj.optString("message", "BES update failed")
4510
- lastBesOtaProgress = -1 // Reset for next OTA
4511
- } else if ("update" == type) {
4512
- besOtaStatus = "PROGRESS"
4513
- besOtaProgressVal = progress
4514
5221
  } else {
4515
- // Unknown type, treat as progress
4516
- besOtaStatus = "PROGRESS"
4517
- besOtaProgressVal = progress
5222
+ lastBesOtaProgress = besOtaProgressVal
4518
5223
  }
4519
5224
 
4520
- val syntheticStatus: String
4521
- if ("FINISHED" == besOtaStatus) {
4522
- // The glasses power-cycle right after the final BES tick, so a
4523
- // session whose BES step is the LAST step never gets a follow-up
4524
- // ota_status from the glasses — consumers mapping on this synthetic
4525
- // status would otherwise never see a terminal state. Emit "complete"
4526
- // for the final step; mid-session BES steps keep "step_complete" so
4527
- // session-level trackers advance normally. Unknown sessions
4528
- // (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
4529
- // ota_status) conservatively keep "step_complete".
4530
- syntheticStatus =
4531
- if (cachedOtaTotalSteps > 0 &&
4532
- cachedOtaCurrentStep >= cachedOtaTotalSteps
4533
- )
4534
- "complete"
4535
- else "step_complete"
4536
- } else if ("FAILED" == besOtaStatus) {
4537
- syntheticStatus = "failed"
4538
- } else {
4539
- syntheticStatus = "in_progress"
5225
+ val syntheticStatus = when (besOtaStatus) {
5226
+ "FINISHED" -> "step_complete"
5227
+ "FAILED" -> "failed"
5228
+ else -> "in_progress"
4540
5229
  }
4541
5230
  val sid = if (cachedOtaSessionId != null) cachedOtaSessionId!! else ""
4542
5231
  val totalSteps = if (cachedOtaTotalSteps > 0) cachedOtaTotalSteps else 1
@@ -5001,6 +5690,10 @@ class MentraLive : SGCManager() {
5001
5690
  Bridge.log("LIVE: Skipping heartbeat - glasses not ready or not connected")
5002
5691
  return
5003
5692
  }
5693
+ if (besOtaHeartbeatGuard.shouldSuppress(SystemClock.elapsedRealtime())) {
5694
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5695
+ return
5696
+ }
5004
5697
 
5005
5698
  try {
5006
5699
  // Send ping message (no ACK needed for heartbeats)
@@ -5092,6 +5785,8 @@ class MentraLive : SGCManager() {
5092
5785
 
5093
5786
  /** Start the micbeat mechanism - periodically enable custom audio TX */
5094
5787
  private fun startMicBeat() {
5788
+ // A resumed custom-audio stream starts a new sequence domain.
5789
+ DeviceManager.getInstance().resetMicSequenceBaseline()
5095
5790
  micOnCount++
5096
5791
  Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
5097
5792
  micBeatCount = 0
@@ -5233,8 +5928,16 @@ class MentraLive : SGCManager() {
5233
5928
  override fun findCompatibleDevices() {
5234
5929
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5235
5930
 
5236
- // Clear reconnection mode when user manually scans
5931
+ // A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
5932
+ // and replace any active fast reconnect scan with discovery mode.
5933
+ reconnectGeneration++
5934
+ if (isScanning) {
5935
+ stopScan()
5936
+ }
5937
+ manualDiscoveryActive = true
5237
5938
  isReconnecting = false
5939
+ // Discovery-only: do not treat leftover savedDeviceName as a connect target.
5940
+ explicitConnectByName = false
5238
5941
 
5239
5942
  if (bluetoothAdapter == null) {
5240
5943
  Log.e(TAG, "Bluetooth not available")
@@ -5246,50 +5949,123 @@ class MentraLive : SGCManager() {
5246
5949
  return
5247
5950
  }
5248
5951
 
5952
+ // Stale Classic ACL keeps Mentra Live from advertising — drop A2DP/HFP (keep bond)
5953
+ // so a pairing/discovery scan can see the unit again. Keep the active session's
5954
+ // audio; tear down other bonded Lives so a second unit can advertise.
5955
+ releaseStaleClassicForDiscovery()
5956
+
5249
5957
  // Start scanning for BLE devices
5250
5958
  startScan()
5251
5959
  }
5252
5960
 
5961
+ private fun releaseStaleClassicForDiscovery() {
5962
+ val adapter = bluetoothAdapter ?: return
5963
+ val bonded =
5964
+ try {
5965
+ adapter.bondedDevices?.toList().orEmpty()
5966
+ } catch (e: Exception) {
5967
+ Bridge.log("LIVE: Classic: discovery release — cannot list bonded: " + e.message)
5968
+ return
5969
+ }
5970
+ val mentraBonded =
5971
+ bonded.filter { isMentraLiveBluetoothName(safeDeviceName(it)) }
5972
+ if (mentraBonded.isEmpty()) {
5973
+ return
5974
+ }
5975
+ val currentAddress = connectedDevice?.address
5976
+ for (device in mentraBonded) {
5977
+ if (currentAddress != null && device.address.equals(currentAddress, ignoreCase = true)) {
5978
+ continue
5979
+ }
5980
+ Bridge.log(
5981
+ "LIVE: Classic: releasing stale A2DP/HFP before discovery for " +
5982
+ safeDeviceName(device) +
5983
+ " (" +
5984
+ device.address +
5985
+ ")"
5986
+ )
5987
+ disconnectClassicProfiles(device)
5988
+ }
5989
+ }
5990
+
5253
5991
  override fun connectById(id: String) {
5992
+ if (pairingYieldActive) {
5993
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
5994
+ return
5995
+ }
5996
+ reconnectGeneration++
5997
+ manualDiscoveryActive = false
5998
+ if (isScanning) {
5999
+ stopScan()
6000
+ }
6001
+ isReconnecting = false
5254
6002
  Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
5255
6003
  savedDeviceName = id
5256
- // // Persist immediately so reconnection logic can find it in case this connection fails
5257
- // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5258
- // prefs.edit().putString(PREF_DEVICE_NAME, id).apply();
5259
- // Log.i(TAG, "🔌 💾 Saved device name for future reconnection: " +
5260
- // connectedDevice.getName());
5261
- // Bridge.log("LIVE: Saved device name for future reconnection: " +
5262
- // connectedDevice.getName());
6004
+ // Without this, connectToSmartGlasses() falls back to a name scan with
6005
+ // isReconnecting=false and never GATT-connects (loading screen hangs).
6006
+ explicitConnectByName = true
5263
6007
  connectToSmartGlasses()
5264
6008
  }
5265
6009
 
6010
+ /**
6011
+ * True while Android is showing (or about to show) the system pairing dialog for
6012
+ * CTKD Classic bonding. Calling [removeBond] / tearing down the SGC here aborts that
6013
+ * dialog and forces a full re-pair.
6014
+ */
6015
+ fun isCtkdBondingInProgress(): Boolean {
6016
+ val device = connectedDevice ?: return false
6017
+ return device.bondState == BluetoothDevice.BOND_BONDING
6018
+ }
6019
+
5266
6020
  override fun forget() {
5267
6021
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
5268
6022
 
5269
- // Clear saved device name to prevent reconnection to this device
5270
- // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5271
- // prefs.edit().remove(PREF_DEVICE_NAME).apply();
5272
- // Bridge.log("LIVE: Cleared saved device name");
6023
+ // Clear saved device name so a leftover name can't bypass the pairing-mode
6024
+ // discovery filter for this unit on a subsequent rescan (parity with iOS forget()).
6025
+ savedDeviceName = ""
5273
6026
 
5274
6027
  // Reset reconnection state
5275
6028
  reconnectAttempts = 0
5276
6029
  isReconnecting = false
6030
+ manualDiscoveryActive = false
6031
+ explicitConnectByName = false
5277
6032
 
5278
- // Remove BT Classic bond - this is the ONLY place where we unbond,
5279
- // ensuring bond is only removed when user explicitly unpairs
5280
- if (connectedDevice != null) {
5281
- Bridge.log("LIVE: CTKD: Removing BT bond on explicit unpair")
5282
- removeBond(connectedDevice!!)
5283
- }
5284
-
6033
+ val ctx = context ?: Bridge.getContext()
6034
+ val bondedAddress = connectedDevice?.address
5285
6035
  if (isScanning) {
5286
6036
  stopScan()
5287
6037
  emitStopScanEvent()
5288
6038
  }
5289
- disconnect()
6039
+ // Tell glasses to drop Mentra owner before tearing the link. removeBond
6040
+ // alone leaves glasses NV owned, so the next pair hits OWNER_ONLY.
6041
+ try {
6042
+ val unpair = JSONObject()
6043
+ unpair.put("type", "unpair")
6044
+ sendJsonWithoutAck(unpair, true)
6045
+ } catch (e: JSONException) {
6046
+ Log.e(TAG, "Error creating unpair command", e)
6047
+ }
6048
+ // Give the unpair write a beat to leave GATT before destroy() cancels the queue.
6049
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
6050
+ unpairFlushPending = true
6051
+ val flush =
6052
+ Runnable {
6053
+ unpairFlushPending = false
6054
+ unpairFlushRunnable = null
6055
+ // Drop GATT first. removeBond while the BLE link is still encrypted often
6056
+ // returns true on Samsung but the OS Bluetooth UI keeps the dual-mode pair.
6057
+ disconnect()
6058
+ unbondBondedMentraLiveDevices(ctx, bondedAddress)
6059
+ }
6060
+ unpairFlushRunnable = flush
6061
+ handler.postDelayed(flush, UNPAIR_FLUSH_MS)
5290
6062
  }
5291
6063
 
5292
6064
  override fun disconnect() {
6065
+ if (unpairFlushPending) {
6066
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6067
+ return
6068
+ }
5293
6069
  Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
5294
6070
  destroy()
5295
6071
  }
@@ -5327,6 +6103,10 @@ class MentraLive : SGCManager() {
5327
6103
  }
5328
6104
 
5329
6105
  fun connectToSmartGlasses() {
6106
+ if (pairingYieldActive) {
6107
+ Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6108
+ return
6109
+ }
5330
6110
  Bridge.log("LIVE: Connecting to Mentra Live glasses")
5331
6111
  updateConnectionState(ConnTypes.CONNECTING)
5332
6112
 
@@ -5355,7 +6135,13 @@ class MentraLive : SGCManager() {
5355
6135
  // var context = Bridge.getContext();
5356
6136
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5357
6137
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
5358
- val lastDeviceAddress = DeviceStore.get("bluetooth", "device_address") as String?
6138
+ val pendingAddress =
6139
+ (DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
6140
+ it.isNotEmpty()
6141
+ }
6142
+ val lastDeviceAddress =
6143
+ pendingAddress
6144
+ ?: (DeviceStore.get("bluetooth", "device_address") as String?)
5359
6145
 
5360
6146
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
5361
6147
  // Connect to last known device if available
@@ -5366,6 +6152,7 @@ class MentraLive : SGCManager() {
5366
6152
  Bridge.log(
5367
6153
  "LIVE: Found saved device, connecting directly: " + lastDeviceAddress
5368
6154
  )
6155
+ prepareClassicBeforeBleConnect(device)
5369
6156
  connectToDevice(device)
5370
6157
  } else {
5371
6158
  Bridge.log(
@@ -5381,12 +6168,62 @@ class MentraLive : SGCManager() {
5381
6168
  startScan() // Fallback to scanning
5382
6169
  }
5383
6170
  } else {
5384
- // If no last known device, start scanning for devices
5385
- Bridge.log("LIVE: No last known device, starting scan")
5386
- startScan()
6171
+ // Pairing may only have a name (or address wasn't stashed). Prefer a bonded
6172
+ // device match over scan Classic-connected glasses often stop advertising.
6173
+ val bonded = findBondedDeviceByName(savedDeviceName)
6174
+ if (bonded != null) {
6175
+ Bridge.log(
6176
+ "LIVE: No saved address — using bonded device " +
6177
+ safeDeviceName(bonded) +
6178
+ " (" +
6179
+ bonded.address +
6180
+ ")"
6181
+ )
6182
+ prepareClassicBeforeBleConnect(bonded)
6183
+ connectToDevice(bonded)
6184
+ } else {
6185
+ Bridge.log("LIVE: No last known device, starting scan")
6186
+ startScan()
6187
+ }
5387
6188
  }
5388
6189
  }
5389
6190
 
6191
+ private fun findBondedDeviceByName(name: String): BluetoothDevice? {
6192
+ if (name.isEmpty() || bluetoothAdapter == null) {
6193
+ return null
6194
+ }
6195
+ return try {
6196
+ bluetoothAdapter!!.bondedDevices?.firstOrNull { safeDeviceName(it) == name }
6197
+ } catch (e: SecurityException) {
6198
+ Bridge.log("LIVE: Unable to read bonded devices: " + e.message)
6199
+ null
6200
+ } catch (e: Exception) {
6201
+ Bridge.log("LIVE: Error reading bonded devices: " + e.message)
6202
+ null
6203
+ }
6204
+ }
6205
+
6206
+ /**
6207
+ * Fresh pairing / discovery may need Classic down so glasses leave a stuck ACL.
6208
+ * Owner reconnect must NOT tear Classic first: BES BLE-PAIR-GUARD historically
6209
+ * admitted Mentra App RPAs only while Classic owner ACL was up — tearing it
6210
+ * caused GATT status=19 reject loops after Mentra App disconnect.
6211
+ */
6212
+ private fun prepareClassicBeforeBleConnect(device: BluetoothDevice) {
6213
+ if (device.bondState != BluetoothDevice.BOND_BONDED) {
6214
+ return
6215
+ }
6216
+ // Owner reconnect and connectById both go through connectToSmartGlasses(),
6217
+ // which clears isReconnecting first. Leave Classic up: tearing A2DP/HFP
6218
+ // here historically caused GATT status=19 reject loops. Discovery teardown
6219
+ // lives in releaseStaleClassicForDiscovery() for pairing scans only.
6220
+ Bridge.log(
6221
+ "LIVE: Classic: bonded target — leave A2DP/HFP up for BLE admit (" +
6222
+ safeDeviceName(device) +
6223
+ ")"
6224
+ )
6225
+ }
6226
+
5390
6227
  override fun setMicEnabled(enabled: Boolean) {
5391
6228
  Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
5392
6229
 
@@ -5830,41 +6667,67 @@ class MentraLive : SGCManager() {
5830
6667
  Bridge.log(
5831
6668
  "LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
5832
6669
  )
6670
+ markPairingTiming(
6671
+ "ctkd_bonded",
6672
+ "glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
6673
+ )
5833
6674
  if (isKilled) {
5834
6675
  Bridge.log(
5835
6676
  "LIVE: CTKD: Ignoring bond complete — SGC destroyed"
5836
6677
  )
5837
6678
  return@run
5838
6679
  }
5839
- isBtClassicConnected = true
5840
- audioConnected = true
5841
6680
  bondingRetryCount =
5842
6681
  0 // Reset retry counter on success
5843
- // Both BLE and BT Classic are now connected via
5844
- // CTKD
5845
-
5846
- // If glasses_ready was already received, now we're
5847
- // fully ready
6682
+ // Bond != audio profile. Explicitly open A2DP
6683
+ // after createBond() completes (already-bonded
6684
+ // path does this at GATT connect).
6685
+ // markAudioConnected / sendAudioConnected fire
6686
+ // when the A2DP path completes.
6687
+ connectA2dpProfile(device)
6688
+
6689
+ // Safety net: if glasses_ready somehow missed
6690
+ // setting fullyBooted, catch up here.
5848
6691
  if (glassesReadyReceived) {
5849
- Bridge.log(
5850
- "LIVE: Audio: Both audio and glasses_ready confirmed - marking as fully connected"
5851
- )
5852
- DeviceStore.apply(
5853
- "glasses",
5854
- "fullyBooted",
5855
- true
5856
- )
5857
- updateConnectionState(ConnTypes.CONNECTED)
6692
+ val alreadyBooted =
6693
+ DeviceStore.store.get(
6694
+ "glasses",
6695
+ "fullyBooted"
6696
+ ) as? Boolean
6697
+ ?: false
6698
+ if (!alreadyBooted) {
6699
+ Bridge.log(
6700
+ "LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
6701
+ )
6702
+ endPairingTiming(
6703
+ "fully_connected",
6704
+ "via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
6705
+ )
6706
+ DeviceStore.apply(
6707
+ "glasses",
6708
+ "fullyBooted",
6709
+ true
6710
+ )
6711
+ updateConnectionState(ConnTypes.CONNECTED)
6712
+ } else {
6713
+ Bridge.log(
6714
+ "LIVE: Audio: CTKD bonded after pairing UI already advanced"
6715
+ )
6716
+ markPairingTiming(
6717
+ "ctkd_bonded_after_fully_connected",
6718
+ "queueSize=${sendQueue.size}"
6719
+ )
6720
+ }
5858
6721
  }
5859
-
5860
- // Send audio connected event for platform parity
5861
- // with iOS
5862
- Bridge.sendAudioConnected(device.name)
5863
6722
  }
5864
6723
  BluetoothDevice.BOND_NONE -> {
5865
6724
  Bridge.log(
5866
6725
  "LIVE: CTKD: ❌ Bonding failed or removed for device"
5867
6726
  )
6727
+ markPairingTiming(
6728
+ "ctkd_bond_none",
6729
+ "prev=$previousBondState queueSize=${sendQueue.size}"
6730
+ )
5868
6731
  isBtClassicConnected = false
5869
6732
  audioConnected = false
5870
6733
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
@@ -5925,10 +6788,12 @@ class MentraLive : SGCManager() {
5925
6788
  Bridge.sendAudioDisconnected()
5926
6789
  }
5927
6790
  }
5928
- BluetoothDevice.BOND_BONDING ->
6791
+ BluetoothDevice.BOND_BONDING -> {
5929
6792
  Bridge.log(
5930
6793
  "LIVE: CTKD: 🔄 Bonding in progress with device"
5931
6794
  )
6795
+ markPairingTiming("ctkd_bonding")
6796
+ }
5932
6797
  else ->
5933
6798
  Bridge.log(
5934
6799
  "LIVE: CTKD: Unknown bond state: " + bondState
@@ -5963,6 +6828,39 @@ class MentraLive : SGCManager() {
5963
6828
  }
5964
6829
  }
5965
6830
 
6831
+ /**
6832
+ * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
6833
+ * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
6834
+ * (peer terminate) on reconnect when A2DP is already up.
6835
+ */
6836
+ private fun maybeInitiateCtkdAudioAfterBleReady() {
6837
+ if (isKilled) {
6838
+ return
6839
+ }
6840
+ val device = connectedDevice
6841
+ if (device == null) {
6842
+ Bridge.log("LIVE: CTKD: Skipping initiate — no connected device")
6843
+ return
6844
+ }
6845
+ if (ctkdInitiatedThisGattSession) {
6846
+ return
6847
+ }
6848
+ ctkdInitiatedThisGattSession = true
6849
+ Bridge.log(
6850
+ "LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
6851
+ device.bondState +
6852
+ ")"
6853
+ )
6854
+ markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
6855
+ if (device.bondState == BluetoothDevice.BOND_BONDED) {
6856
+ Bridge.log("LIVE: CTKD: Device is already bonded - connecting A2DP audio profile")
6857
+ markPairingTiming("ctkd_already_bonded")
6858
+ connectA2dpProfile(device)
6859
+ } else {
6860
+ createBond(device)
6861
+ }
6862
+ }
6863
+
5966
6864
  /**
5967
6865
  * Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
5968
6866
  * BLE and BT Classic connections automatically
@@ -6069,6 +6967,7 @@ class MentraLive : SGCManager() {
6069
6967
 
6070
6968
  if (state == BluetoothProfile.STATE_CONNECTED) {
6071
6969
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
6970
+ a2dpConnectAttempts = 0
6072
6971
  markAudioConnected(device.name)
6073
6972
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
6074
6973
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
@@ -6077,37 +6976,50 @@ class MentraLive : SGCManager() {
6077
6976
  BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
6078
6977
  val result = connectMethod.invoke(a2dpProfile, device) as Boolean
6079
6978
  Bridge.log("LIVE: A2DP: Connect initiated, result: " + result)
6080
-
6081
- // Note: connect() is async. We mark as connected optimistically because:
6082
- // 1. The device is already bonded, so connection should succeed
6083
- // 2. Android will handle the actual A2DP connection in the background
6084
- // 3. If it fails, the user can still use BLE audio (LC3)
6085
- markAudioConnected(device.name)
6979
+ if (result) {
6980
+ scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
6981
+ } else {
6982
+ scheduleA2dpConnectRetry(device, "connect() returned false")
6983
+ }
6086
6984
  } else if (state == BluetoothProfile.STATE_CONNECTING) {
6087
- Bridge.log("LIVE: A2DP: Already connecting, marking audio as connected")
6088
- markAudioConnected(device.name)
6985
+ Bridge.log("LIVE: A2DP: Already connecting waiting for STATE_CONNECTED")
6986
+ scheduleA2dpConnectRetry(device, "still connecting")
6089
6987
  } else {
6090
- // STATE_DISCONNECTING - wait and retry
6091
- Bridge.log("LIVE: A2DP: Device disconnecting, will retry in 500ms")
6092
- handler.postDelayed(
6093
- Runnable {
6094
- if (isKilled) {
6095
- return@Runnable
6096
- }
6097
- if (connectedDevice != null && a2dpProfile != null) {
6098
- connectA2dpWithProxy(connectedDevice)
6099
- }
6100
- },
6101
- 500
6102
- )
6988
+ scheduleA2dpConnectRetry(device, "device disconnecting")
6103
6989
  }
6104
6990
  } catch (e: Exception) {
6105
6991
  Bridge.log("LIVE: A2DP: Error connecting: " + e.message)
6106
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6107
- markAudioConnected(device.name)
6992
+ scheduleA2dpConnectRetry(device, "exception: ${e.message}")
6108
6993
  }
6109
6994
  }
6110
6995
 
6996
+ private fun scheduleA2dpConnectRetry(device: BluetoothDevice, reason: String) {
6997
+ a2dpConnectAttempts++
6998
+ if (a2dpConnectAttempts > A2DP_CONNECT_MAX_ATTEMPTS) {
6999
+ Bridge.log(
7000
+ "LIVE: A2DP: Giving up after $A2DP_CONNECT_MAX_ATTEMPTS attempts ($reason). " +
7001
+ "Classic bond may exist, but A2DP/HFP is not up. BLE/LC3 still works."
7002
+ )
7003
+ return
7004
+ }
7005
+ Bridge.log(
7006
+ "LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
7007
+ "${A2DP_CONNECT_RETRY_MS}ms ($reason)"
7008
+ )
7009
+ handler.postDelayed(
7010
+ Runnable {
7011
+ if (isKilled || pairingYieldActive || connectedDevice == null) {
7012
+ return@Runnable
7013
+ }
7014
+ val target = connectedDevice ?: return@Runnable
7015
+ if (a2dpProfile != null) {
7016
+ connectA2dpWithProxy(target)
7017
+ }
7018
+ },
7019
+ A2DP_CONNECT_RETRY_MS
7020
+ )
7021
+ }
7022
+
6111
7023
  /** Helper to mark audio as connected and notify */
6112
7024
  private fun markAudioConnected(deviceName: String?) {
6113
7025
  if (isKilled) {
@@ -6123,8 +7035,14 @@ class MentraLive : SGCManager() {
6123
7035
  Bridge.log(
6124
7036
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
6125
7037
  )
7038
+ endPairingTiming(
7039
+ "fully_connected",
7040
+ "via=a2dp+glasses_ready queueSize=${sendQueue.size}"
7041
+ )
6126
7042
  DeviceStore.apply("glasses", "fullyBooted", true)
6127
7043
  updateConnectionState(ConnTypes.CONNECTED)
7044
+ } else {
7045
+ markPairingTiming("a2dp_connected_waiting_glasses_ready")
6128
7046
  }
6129
7047
  }
6130
7048
 
@@ -6148,6 +7066,7 @@ class MentraLive : SGCManager() {
6148
7066
  }
6149
7067
 
6150
7068
  Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
7069
+ a2dpConnectAttempts = 0
6151
7070
 
6152
7071
  // If we already have the proxy, try to connect directly
6153
7072
  if (a2dpProfile != null) {
@@ -6168,16 +7087,133 @@ class MentraLive : SGCManager() {
6168
7087
  Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
6169
7088
  } else {
6170
7089
  Bridge.log(
6171
- "LIVE: A2DP: Failed to get profile proxy, marking audio connected anyway"
7090
+ "LIVE: A2DP: Failed to get profile proxy Classic audio not connected"
6172
7091
  )
6173
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6174
- markAudioConnected(device.name)
6175
7092
  }
6176
7093
  } else {
6177
7094
  Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
6178
7095
  }
6179
7096
  }
6180
7097
 
7098
+ /**
7099
+ * Drop Classic audio profiles (A2DP + HFP). App disconnect previously only closed BLE GATT /
7100
+ * the A2DP proxy, so Android Settings still showed Mentra Live as connected and the next BLE
7101
+ * reconnect fought a live Classic link (GATT 19). Bond is intentionally kept for reconnect —
7102
+ * only [forget] calls removeBond.
7103
+ */
7104
+ private fun disconnectClassicProfiles(device: BluetoothDevice?) {
7105
+ if (device == null || bluetoothAdapter == null || context == null) {
7106
+ Bridge.log("LIVE: Classic: skip profile teardown — missing device/adapter/context")
7107
+ return
7108
+ }
7109
+ Bridge.log(
7110
+ "LIVE: Classic: tearing down A2DP/HFP for " +
7111
+ safeDeviceName(device) +
7112
+ " (" +
7113
+ device.address +
7114
+ ")"
7115
+ )
7116
+ val isActiveDevice =
7117
+ connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7118
+ if (isActiveDevice) {
7119
+ isBtClassicConnected = false
7120
+ audioConnected = false
7121
+ }
7122
+
7123
+ if (a2dpProfile != null) {
7124
+ invokeHiddenProfileDisconnect(
7125
+ a2dpProfile!!,
7126
+ device,
7127
+ BluetoothA2dp::class.java,
7128
+ "A2DP"
7129
+ )
7130
+ } else {
7131
+ requestProfileDisconnect(BluetoothProfile.A2DP, device, BluetoothA2dp::class.java, "A2DP")
7132
+ }
7133
+ // HFP/SCO often stays up after A2DP-only teardown (shows as "Bluetooth Headset" in routes).
7134
+ requestProfileDisconnect(
7135
+ BluetoothProfile.HEADSET,
7136
+ device,
7137
+ BluetoothHeadset::class.java,
7138
+ "HFP"
7139
+ )
7140
+ }
7141
+
7142
+ private fun invokeHiddenProfileDisconnect(
7143
+ proxy: BluetoothProfile,
7144
+ device: BluetoothDevice,
7145
+ proxyClass: Class<*>,
7146
+ label: String
7147
+ ) {
7148
+ try {
7149
+ val state = proxy.getConnectionState(device)
7150
+ Bridge.log("LIVE: Classic: " + label + " state=" + state)
7151
+ if (state == BluetoothProfile.STATE_CONNECTED ||
7152
+ state == BluetoothProfile.STATE_CONNECTING
7153
+ ) {
7154
+ val disconnectMethod =
7155
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
7156
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
7157
+ Bridge.log("LIVE: Classic: " + label + " disconnect result=" + result)
7158
+ } else {
7159
+ Bridge.log("LIVE: Classic: " + label + " already idle — nothing to disconnect")
7160
+ }
7161
+ } catch (e: Exception) {
7162
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
7163
+ }
7164
+ }
7165
+
7166
+ private fun requestProfileDisconnect(
7167
+ profile: Int,
7168
+ device: BluetoothDevice,
7169
+ proxyClass: Class<*>,
7170
+ label: String
7171
+ ) {
7172
+ val adapter = bluetoothAdapter ?: return
7173
+ val ctx = context ?: return
7174
+ try {
7175
+ val ok =
7176
+ adapter.getProfileProxy(
7177
+ ctx,
7178
+ object : BluetoothProfile.ServiceListener {
7179
+ override fun onServiceConnected(
7180
+ profileType: Int,
7181
+ proxy: BluetoothProfile
7182
+ ) {
7183
+ if (profileType != profile) {
7184
+ return
7185
+ }
7186
+ try {
7187
+ invokeHiddenProfileDisconnect(
7188
+ proxy,
7189
+ device,
7190
+ proxyClass,
7191
+ label
7192
+ )
7193
+ } finally {
7194
+ try {
7195
+ adapter.closeProfileProxy(profile, proxy)
7196
+ } catch (e: Exception) {
7197
+ Bridge.log(
7198
+ "LIVE: Classic: error closing " +
7199
+ label +
7200
+ " proxy: " +
7201
+ e.message
7202
+ )
7203
+ }
7204
+ }
7205
+ }
7206
+
7207
+ override fun onServiceDisconnected(profileType: Int) {}
7208
+ },
7209
+ profile
7210
+ )
7211
+ Bridge.log("LIVE: Classic: requested " + label + " proxy for disconnect, ok=" + ok)
7212
+ } catch (e: Exception) {
7213
+ Bridge.log("LIVE: Classic: failed to request " + label + " proxy: " + e.message)
7214
+ }
7215
+ }
7216
+
6181
7217
  /** Close the A2DP profile proxy */
6182
7218
  private fun closeA2dpProxy() {
6183
7219
  if (a2dpProfile != null && bluetoothAdapter != null) {
@@ -6193,6 +7229,14 @@ class MentraLive : SGCManager() {
6193
7229
 
6194
7230
  // Mark as killed to prevent reconnection attempts
6195
7231
  isKilled = true
7232
+ explicitConnectByName = false
7233
+ pairingYieldActive = false
7234
+ pairingYieldAwaitingReclaim = false
7235
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
7236
+ pairingYieldEndRunnable = null
7237
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
7238
+ unpairFlushRunnable = null
7239
+ unpairFlushPending = false
6196
7240
 
6197
7241
  // Stop scanning if in progress
6198
7242
  if (isScanning) {
@@ -6203,7 +7247,10 @@ class MentraLive : SGCManager() {
6203
7247
  // CTKD Implementation: Unregister bonding receiver
6204
7248
  unregisterBondingReceiver()
6205
7249
 
6206
- // Close A2DP profile proxy
7250
+ // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7251
+ // phone's system Bluetooth UI showing Mentra Live still connected.
7252
+ val classicDevice = connectedDevice
7253
+ disconnectClassicProfiles(classicDevice)
6207
7254
  closeA2dpProxy()
6208
7255
 
6209
7256
  // Stop readiness check loop
@@ -6274,6 +7321,9 @@ class MentraLive : SGCManager() {
6274
7321
  reconnectAttempts = 0
6275
7322
  isReconnecting = false
6276
7323
  glassesReady = false
7324
+ glassesReadyReceived = false
7325
+ audioConnected = false
7326
+ ctkdInitiatedThisGattSession = false
6277
7327
  DeviceStore.apply("glasses", "fullyBooted", false)
6278
7328
  updateConnectionState(ConnTypes.DISCONNECTED)
6279
7329
 
@@ -7293,6 +8343,7 @@ class MentraLive : SGCManager() {
7293
8343
  /** Sends phone_ready to (re)start the glasses-driven readiness flow. */
7294
8344
  private fun sendPhoneReady(reason: String) {
7295
8345
  Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
8346
+ markPairingTiming("phone_ready_sent", "reason=$reason queueSize=${sendQueue.size}")
7296
8347
  val readyMsg = JSONObject()
7297
8348
  readyMsg.put("type", "phone_ready")
7298
8349
  readyMsg.put("timestamp", System.currentTimeMillis())
@@ -7416,6 +8467,10 @@ class MentraLive : SGCManager() {
7416
8467
  payload
7417
8468
  )
7418
8469
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
8470
+ markPairingTiming(
8471
+ "wire_handshake_sent",
8472
+ "attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
8473
+ )
7419
8474
  wireHandshakeQueued = true
7420
8475
  wireHandshakeSentGeneration = wireSessionGeneration
7421
8476
  queueData(packed, null)
@@ -7442,6 +8497,10 @@ class MentraLive : SGCManager() {
7442
8497
  WIRE_HANDSHAKE_MAX_ATTEMPTS +
7443
8498
  ") - re-arming"
7444
8499
  )
8500
+ markPairingTiming(
8501
+ "wire_handshake_unanswered",
8502
+ "attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
8503
+ )
7445
8504
  wireHandshakeQueued = false
7446
8505
  if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7447
8506
  maybeSendWireHandshake()
@@ -7470,6 +8529,7 @@ class MentraLive : SGCManager() {
7470
8529
  Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7471
8530
  return
7472
8531
  }
8532
+ markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
7473
8533
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7474
8534
  }
7475
8535
 
@@ -7570,10 +8630,22 @@ class MentraLive : SGCManager() {
7570
8630
  * @param data The string data to be sent to the glasses
7571
8631
  */
7572
8632
  fun sendDataToGlasses(data: String?, wakeup: Boolean) {
8633
+ sendDataToGlasses(data, wakeup, bleSessionGeneration.get())
8634
+ }
8635
+
8636
+ private fun sendDataToGlasses(
8637
+ data: String?,
8638
+ wakeup: Boolean,
8639
+ sessionGeneration: Long
8640
+ ) {
7573
8641
  if (data == null || data.isEmpty()) {
7574
8642
  Log.e(TAG, "Cannot send empty data to glasses")
7575
8643
  return
7576
8644
  }
8645
+ if (sessionGeneration != bleSessionGeneration.get()) {
8646
+ Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
8647
+ return
8648
+ }
7577
8649
 
7578
8650
  val wireData =
7579
8651
  try {
@@ -7600,7 +8672,13 @@ class MentraLive : SGCManager() {
7600
8672
  }
7601
8673
 
7602
8674
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
7603
- sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
8675
+ sendDataToGlassesBinary(
8676
+ wireData,
8677
+ wakeup,
8678
+ commandTraceInfo,
8679
+ isPhotoRequest,
8680
+ sessionGeneration
8681
+ )
7604
8682
  return
7605
8683
  }
7606
8684
 
@@ -7679,7 +8757,7 @@ class MentraLive : SGCManager() {
7679
8757
  )
7680
8758
 
7681
8759
  // Queue the chunk for sending
7682
- queueData(packedData, trace)
8760
+ queueData(packedData, trace, sessionGeneration)
7683
8761
 
7684
8762
  // Add small delay between chunks to avoid overwhelming the connection
7685
8763
  if (i < chunks.size - 1) {
@@ -7720,7 +8798,7 @@ class MentraLive : SGCManager() {
7720
8798
  )
7721
8799
 
7722
8800
  // Queue the data for sending
7723
- queueData(packedData, trace)
8801
+ queueData(packedData, trace, sessionGeneration)
7724
8802
  if (isPhotoRequest) {
7725
8803
  Bridge.log(
7726
8804
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7738,7 +8816,8 @@ class MentraLive : SGCManager() {
7738
8816
  data: String,
7739
8817
  wakeup: Boolean,
7740
8818
  commandTraceInfo: OutgoingBleCommandTraceInfo,
7741
- isPhotoRequest: Boolean
8819
+ isPhotoRequest: Boolean,
8820
+ sessionGeneration: Long
7742
8821
  ) {
7743
8822
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7744
8823
  var messageId = 0
@@ -7790,7 +8869,7 @@ class MentraLive : SGCManager() {
7790
8869
  "wireProtocol" to "v2"
7791
8870
  )
7792
8871
  )
7793
- queueData(packedData, trace)
8872
+ queueData(packedData, trace, sessionGeneration)
7794
8873
 
7795
8874
  if (i < fragments.size - 1) {
7796
8875
  try {
@@ -8124,6 +9203,19 @@ class MentraLive : SGCManager() {
8124
9203
  }
8125
9204
  }
8126
9205
 
9206
+ override fun sendWifiAdbState(enabled: Boolean) {
9207
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state to glasses - enabled: " + enabled)
9208
+ try {
9209
+ val command = JSONObject()
9210
+ command.put("type", "set_wifi_adb_state")
9211
+ command.put("enabled", enabled)
9212
+ sendJson(command, true)
9213
+ Bridge.log("LIVE: 🔧 ✅ Wi-Fi ADB state command sent successfully")
9214
+ } catch (e: JSONException) {
9215
+ Log.e(TAG, "🔧 💥 Error creating Wi-Fi ADB state JSON", e)
9216
+ }
9217
+ }
9218
+
8127
9219
  override fun sendSetSystemTime(timestampMs: Long) {
8128
9220
  Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
8129
9221
  try {
@@ -9311,6 +10403,21 @@ class MentraLive : SGCManager() {
9311
10403
  stopVideoRecording(requestId, null, null)
9312
10404
  }
9313
10405
 
10406
+ override fun queryVideoRecordingStatus(requestId: String) {
10407
+ if (!isConnected) {
10408
+ Log.w(TAG, "Cannot query video recording status - not connected")
10409
+ return
10410
+ }
10411
+ try {
10412
+ val json = JSONObject()
10413
+ json.put("type", "get_video_recording_status")
10414
+ json.put("requestId", requestId)
10415
+ sendJson(json, true)
10416
+ } catch (e: JSONException) {
10417
+ Log.e(TAG, "Failed to create video recording status query", e)
10418
+ }
10419
+ }
10420
+
9314
10421
  override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
9315
10422
  Bridge.log(
9316
10423
  "LIVE: Stopping video recording: requestId=" +
@@ -9361,20 +10468,6 @@ class MentraLive : SGCManager() {
9361
10468
  val sequenceNumber = data[1]
9362
10469
  val receiveTime = System.currentTimeMillis()
9363
10470
 
9364
- // Basic sequence validation
9365
- if (lastReceivedLc3Sequence != (-1).toByte() &&
9366
- (lastReceivedLc3Sequence + 1).toByte() != sequenceNumber
9367
- ) {
9368
- Log.w(
9369
- TAG,
9370
- "LC3 packet sequence mismatch. Expected: " +
9371
- (lastReceivedLc3Sequence + 1) +
9372
- ", Got: " +
9373
- sequenceNumber
9374
- )
9375
- }
9376
- lastReceivedLc3Sequence = sequenceNumber
9377
-
9378
10471
  val lc3Data = Arrays.copyOfRange(data, 2, data.size)
9379
10472
 
9380
10473
  // Enhanced LC3 packet logging and saving
@@ -9386,7 +10479,11 @@ class MentraLive : SGCManager() {
9386
10479
 
9387
10480
  // Forward raw LC3 to DeviceManager (matches iOS behavior)
9388
10481
  // MentraLive uses 40-byte LC3 frames
9389
- DeviceManager.getInstance().handleGlassesMicData(lc3Data, LC3_FRAME_SIZE)
10482
+ DeviceManager.getInstance().handleGlassesMicData(
10483
+ lc3Data,
10484
+ LC3_FRAME_SIZE,
10485
+ sequenceNumber.toInt() and 0xff,
10486
+ )
9390
10487
 
9391
10488
  // Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
9392
10489
  // } else {