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

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 (122) hide show
  1. package/README.md +56 -9
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  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 +1344 -284
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  39. package/build/BluetoothSdk.types.d.ts +58 -2
  40. package/build/BluetoothSdk.types.d.ts.map +1 -1
  41. package/build/BluetoothSdk.types.js.map +1 -1
  42. package/build/_internal.d.ts +2 -0
  43. package/build/_internal.d.ts.map +1 -1
  44. package/build/_internal.js +2 -0
  45. package/build/_internal.js.map +1 -1
  46. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  47. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  48. package/build/_private/BluetoothSdkModule.js +1 -1
  49. package/build/_private/BluetoothSdkModule.js.map +1 -1
  50. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  51. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  52. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  53. package/build/_private/sdkOtaManifest.d.ts +3 -12
  54. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  55. package/build/_private/sdkOtaManifest.js +5 -17
  56. package/build/_private/sdkOtaManifest.js.map +1 -1
  57. package/build/generated/releaseMetadata.d.ts +12 -0
  58. package/build/generated/releaseMetadata.d.ts.map +1 -0
  59. package/build/generated/releaseMetadata.js +10 -0
  60. package/build/generated/releaseMetadata.js.map +1 -0
  61. package/build/index.d.ts +1 -1
  62. package/build/index.d.ts.map +1 -1
  63. package/build/index.js +10 -2
  64. package/build/index.js.map +1 -1
  65. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  66. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  67. package/build/ota-server/MentraOtaServer.types.js +2 -0
  68. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  70. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.js +3 -0
  72. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  73. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  74. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  75. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  76. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  77. package/build/ota-server/index.d.ts +3 -0
  78. package/build/ota-server/index.d.ts.map +1 -0
  79. package/build/ota-server/index.js +3 -0
  80. package/build/ota-server/index.js.map +1 -0
  81. package/expo-module.config.json +3 -2
  82. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  83. package/ios/BluetoothSdkModule.swift +16 -2
  84. package/ios/LocalIPv4.swift +150 -0
  85. package/ios/LocalOtaServer.swift +309 -0
  86. package/ios/MentraBluetoothSDK.podspec +5 -1
  87. package/ios/MentraOtaServerModule.swift +147 -0
  88. package/ios/MentraPhotoReceiverModule.swift +1 -81
  89. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  90. package/ios/Source/Bridge.swift +43 -4
  91. package/ios/Source/DeviceManager.swift +154 -19
  92. package/ios/Source/DeviceStore.swift +2 -0
  93. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  94. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  95. package/ios/Source/OtaManifest.swift +5 -7
  96. package/ios/Source/audio/AudioModels.swift +17 -0
  97. package/ios/Source/camera/CameraModels.swift +1 -0
  98. package/ios/Source/events/BluetoothEvents.swift +27 -0
  99. package/ios/Source/sgcs/MentraLive.swift +469 -77
  100. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  101. package/ios/Source/sgcs/SGCManager.swift +11 -0
  102. package/ios/Source/status/DeviceStatus.swift +23 -0
  103. package/ios/Source/types/DeviceModels.swift +23 -2
  104. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  105. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
  106. package/package.json +5 -1
  107. package/plugin/build/withIos.d.ts +3 -1
  108. package/plugin/build/withIos.js +22 -10
  109. package/scripts/verify-release-package.mjs +34 -0
  110. package/scripts/write-release-metadata.mjs +154 -0
  111. package/scripts/write-release-metadata.test.mjs +29 -0
  112. package/src/BluetoothSdk.types.ts +69 -3
  113. package/src/_internal.ts +2 -0
  114. package/src/_private/BluetoothSdkModule.ts +5 -5
  115. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  116. package/src/_private/sdkOtaManifest.ts +5 -17
  117. package/src/generated/releaseMetadata.ts +20 -0
  118. package/src/index.ts +19 -2
  119. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  120. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  121. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  122. 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,253 @@ 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
+
163
+ private fun manufacturerData(result: ScanResult): ByteArray? {
164
+ return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
165
+ }
166
+
167
+ private fun isPairingDiscoverable(result: ScanResult): Boolean {
168
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result))?.pairingMode ==
169
+ true
170
+ }
171
+
172
+ private fun advertisesPairingFlag(result: ScanResult): Boolean {
173
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result)) != null
174
+ }
175
+
176
+ private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
177
+ val advertisement =
178
+ MentraLivePairingAdvertisementParser.parse(manufacturerData(result))
179
+ ?: return Triple(false, null, false)
180
+ return Triple(advertisement.pairingMode, advertisement.pairingCode, true)
181
+ }
182
+
183
+ fun isMentraLiveBluetoothName(name: String?): Boolean {
184
+ if (name.isNullOrEmpty()) {
185
+ return false
186
+ }
187
+ return name == "Xy_A" ||
188
+ name.startsWith("XyBLE_") ||
189
+ name.startsWith("MENTRA_LIVE_BLE") ||
190
+ name.startsWith("MENTRA_LIVE_BT") ||
191
+ name.lowercase().startsWith("mentra_live")
192
+ }
193
+
194
+ private fun safeBondedDeviceName(device: BluetoothDevice): String {
195
+ return try {
196
+ device.name ?: ""
197
+ } catch (_: SecurityException) {
198
+ ""
199
+ } catch (_: Exception) {
200
+ ""
201
+ }
202
+ }
203
+
204
+ private fun removeBondStandalone(device: BluetoothDevice): Boolean {
205
+ return try {
206
+ val method = device.javaClass.getMethod("removeBond")
207
+ val result = method.invoke(device) as Boolean
208
+ Bridge.log(
209
+ "LIVE: CTKD: removeBond " +
210
+ safeBondedDeviceName(device) +
211
+ " (" +
212
+ device.address +
213
+ ") result=" +
214
+ result
215
+ )
216
+ result
217
+ } catch (e: Exception) {
218
+ Bridge.log("LIVE: CTKD: removeBond error: " + e.message)
219
+ false
220
+ }
221
+ }
222
+
223
+ private fun invokeHiddenProfileDisconnectStandalone(
224
+ proxy: BluetoothProfile,
225
+ device: BluetoothDevice,
226
+ proxyClass: Class<*>,
227
+ label: String
228
+ ) {
229
+ try {
230
+ val state = proxy.getConnectionState(device)
231
+ if (state == BluetoothProfile.STATE_CONNECTED ||
232
+ state == BluetoothProfile.STATE_CONNECTING
233
+ ) {
234
+ val disconnectMethod =
235
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
236
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
237
+ Bridge.log(
238
+ "LIVE: Classic: " +
239
+ label +
240
+ " disconnect " +
241
+ device.address +
242
+ " result=" +
243
+ result
244
+ )
245
+ }
246
+ } catch (e: Exception) {
247
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
248
+ }
249
+ }
250
+
251
+ private fun requestProfileDisconnectStandalone(
252
+ context: Context,
253
+ adapter: BluetoothAdapter,
254
+ profile: Int,
255
+ device: BluetoothDevice,
256
+ proxyClass: Class<*>,
257
+ label: String
258
+ ) {
259
+ try {
260
+ adapter.getProfileProxy(
261
+ context,
262
+ object : BluetoothProfile.ServiceListener {
263
+ override fun onServiceConnected(
264
+ profileType: Int,
265
+ proxy: BluetoothProfile
266
+ ) {
267
+ if (profileType != profile) {
268
+ return
269
+ }
270
+ try {
271
+ invokeHiddenProfileDisconnectStandalone(
272
+ proxy,
273
+ device,
274
+ proxyClass,
275
+ label
276
+ )
277
+ } finally {
278
+ try {
279
+ adapter.closeProfileProxy(profile, proxy)
280
+ } catch (_: Exception) {
281
+ }
282
+ }
283
+ }
284
+
285
+ override fun onServiceDisconnected(profileType: Int) {}
286
+ },
287
+ profile
288
+ )
289
+ } catch (e: Exception) {
290
+ Bridge.log("LIVE: Classic: request " + label + " proxy failed: " + e.message)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Drop A2DP/HFP + removeBond for every bonded Mentra Live unit. Safe when no SGC
296
+ * instance exists — [DeviceManager.forget] often runs after disconnect() nulled sgc
297
+ * (abandonAttempt), which previously skipped MentraLive.forget() entirely and left
298
+ * Classic ACL up so glasses never re-advertised.
299
+ */
300
+ @JvmStatic
301
+ @JvmOverloads
302
+ fun unbondBondedMentraLiveDevices(context: Context?, extraAddress: String? = null) {
303
+ if (context == null) {
304
+ Bridge.log("LIVE: CTKD: unbondBondedMentraLiveDevices — no context")
305
+ return
306
+ }
307
+ val adapter =
308
+ try {
309
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
310
+ ?.adapter
311
+ } catch (e: Exception) {
312
+ Bridge.log("LIVE: CTKD: unbond — adapter error: " + e.message)
313
+ null
314
+ }
315
+ if (adapter == null) {
316
+ Bridge.log("LIVE: CTKD: unbond — BluetoothAdapter unavailable")
317
+ return
318
+ }
319
+ val bonded =
320
+ try {
321
+ adapter.bondedDevices?.toList().orEmpty()
322
+ } catch (e: Exception) {
323
+ Bridge.log("LIVE: CTKD: unbond — cannot list bonded: " + e.message)
324
+ emptyList()
325
+ }
326
+ val extra = extraAddress?.uppercase()
327
+ val targets =
328
+ bonded.filter {
329
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
330
+ (extra != null && it.address.equals(extra, ignoreCase = true))
331
+ }
332
+ if (targets.isEmpty()) {
333
+ Bridge.log("LIVE: CTKD: unbond — no bonded Mentra Live devices")
334
+ return
335
+ }
336
+ for (device in targets) {
337
+ Bridge.log(
338
+ "LIVE: CTKD: tearing down bonded Mentra Live " +
339
+ safeBondedDeviceName(device) +
340
+ " (" +
341
+ device.address +
342
+ ")"
343
+ )
344
+ requestProfileDisconnectStandalone(
345
+ context,
346
+ adapter,
347
+ BluetoothProfile.A2DP,
348
+ device,
349
+ BluetoothA2dp::class.java,
350
+ "A2DP"
351
+ )
352
+ requestProfileDisconnectStandalone(
353
+ context,
354
+ adapter,
355
+ BluetoothProfile.HEADSET,
356
+ device,
357
+ BluetoothHeadset::class.java,
358
+ "HFP"
359
+ )
360
+ removeBondStandalone(device)
361
+ }
362
+ Handler(Looper.getMainLooper())
363
+ .postDelayed({ retryUnbondIfStillPaired(context, extraAddress) }, 700)
364
+ }
365
+
366
+ private fun retryUnbondIfStillPaired(context: Context?, extraAddress: String?) {
367
+ if (context == null) {
368
+ return
369
+ }
370
+ val adapter =
371
+ try {
372
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
373
+ ?.adapter
374
+ } catch (_: Exception) {
375
+ null
376
+ }
377
+ ?: return
378
+ val extra = extraAddress?.uppercase()
379
+ val leftover =
380
+ try {
381
+ adapter.bondedDevices?.toList().orEmpty()
382
+ } catch (_: Exception) {
383
+ emptyList()
384
+ }
385
+ .filter {
386
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
387
+ (extra != null &&
388
+ it.address.equals(extra, ignoreCase = true))
389
+ }
390
+ if (leftover.isEmpty()) {
391
+ Bridge.log("LIVE: CTKD: unbond verify — no Mentra Live bonds remain")
392
+ return
393
+ }
394
+ for (device in leftover) {
395
+ Bridge.log(
396
+ "LIVE: CTKD: unbond retry still paired " +
397
+ safeBondedDeviceName(device) +
398
+ " (" +
399
+ device.address +
400
+ ") bondState=" +
401
+ device.bondState
402
+ )
403
+ removeBondStandalone(device)
404
+ }
405
+ }
406
+
143
407
  private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
144
408
 
145
409
  // Keep-alive parameters
@@ -251,6 +515,16 @@ class MentraLive : SGCManager() {
251
515
 
252
516
  private var reconnectAttempts = 0
253
517
  private var isReconnecting = false // Track if we're in reconnection mode
518
+ // Manual discovery/connect invalidates delayed reconnect work from an earlier GATT failure.
519
+ private var reconnectGeneration = 0
520
+ // Pair Glasses discovery is an explicit no-reconnect zone. It ends only when
521
+ // the user selects a scan result (connectById) or the manager is torn down.
522
+ private var manualDiscoveryActive = false
523
+ // Set by connectById() so a post-disconnect name scan actually GATT-connects.
524
+ // Discovery-only scans (findCompatibleDevices) leave this false so a leftover
525
+ // savedDeviceName cannot auto-connect a spoofed advertiser (iOS parity: iOS
526
+ // connects on saved-name match because connectById writes that name first).
527
+ private var explicitConnectByName = false
254
528
  /**
255
529
  * Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
256
530
  * so glasses can reboot.
@@ -274,18 +548,35 @@ class MentraLive : SGCManager() {
274
548
  private val fileProcessingHandler = Handler(fileProcessingThread.looper)
275
549
  private var scheduler: ScheduledExecutorService? = null
276
550
  private var isScanning = false
551
+ // Prevent an old scan timeout from stopping a newer manual scan.
552
+ private var scanGeneration = 0
277
553
  private var isConnecting = false
278
554
  private var isKilled = false
279
555
 
556
+ /** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
557
+ private var pairingYieldActive = false
558
+ private var pairingYieldEndRunnable: Runnable? = null
559
+ /** After yield ends, auth/not-owner failures may clear saved glasses. */
560
+ private var pairingYieldAwaitingReclaim = false
561
+ /** Hold GATT teardown until the unpair write can leave the phone. */
562
+ private var unpairFlushPending = false
563
+ private var unpairFlushRunnable: Runnable? = null
564
+
280
565
  // CTKD (Cross-Transport Key Derivation) support for BES devices
281
566
  private var isBondingReceiverRegistered = false
282
567
  private var isBtClassicConnected = false
283
568
  private var bondingReceiver: BroadcastReceiver? = null
284
569
  private var bondingRetryCount = 0
285
570
 
571
+ // Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
572
+ private var pairingTimingActive = false
573
+ private var pairingTimingStartMs = 0L
574
+ private var pairingTimingLastMs = 0L
575
+
286
576
  // A2DP profile connection for already-bonded devices
287
577
  private var a2dpProfile: BluetoothA2dp? = null
288
578
  private var isA2dpProxyRegistered = false
579
+ private var a2dpConnectAttempts = 0
289
580
 
290
581
  private data class OutgoingBleCommandTraceInfo(
291
582
  val commandType: String,
@@ -310,7 +601,11 @@ class MentraLive : SGCManager() {
310
601
  val queuedAtMs: Long
311
602
  )
312
603
 
313
- private data class QueuedBleWrite(val data: ByteArray, val trace: BleWriteTrace?)
604
+ private data class QueuedBleWrite(
605
+ val data: ByteArray,
606
+ val trace: BleWriteTrace?,
607
+ val sessionGeneration: Long
608
+ )
314
609
 
315
610
  private var sendQueue = ConcurrentLinkedQueue<QueuedBleWrite>()
316
611
  private val bleWriteTraceSequence = AtomicLong(1)
@@ -603,6 +898,9 @@ class MentraLive : SGCManager() {
603
898
  // Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
604
899
  private var glassesReadyReceived = false
605
900
  private var audioConnected = false
901
+ // CTKD/A2DP must wait until GATT notifications are armed; starting Classic while the
902
+ // BLE link is still discovering services commonly yields GATT status 19 on Samsung.
903
+ private var ctkdInitiatedThisGattSession = false
606
904
 
607
905
  // Micbeat tracking - periodically enable custom audio TX
608
906
  private var micBeatHandler = Handler(Looper.getMainLooper())
@@ -612,12 +910,12 @@ class MentraLive : SGCManager() {
612
910
  // Message tracking for reliable delivery
613
911
  private val pendingMessages = ConcurrentHashMap<Long, PendingMessage>()
614
912
  private val messageIdCounter = AtomicLong(1)
913
+ private val bleSessionGeneration = AtomicLong(0)
615
914
 
616
915
  // Esoteric message ID generation
617
916
  private val secureRandom = SecureRandom()
618
917
  private val deviceId = System.currentTimeMillis() xor Random().nextLong()
619
918
 
620
- private var lastReceivedLc3Sequence: Byte = -1
621
919
  private var lc3SequenceNumber: Byte = 0
622
920
  private var lc3DecoderPtr = 0L
623
921
  private var lc3AudioPlayer: Lc3Player? = null
@@ -637,7 +935,8 @@ class MentraLive : SGCManager() {
637
935
  val messageData: String,
638
936
  val timestamp: Long,
639
937
  val retryCount: Int,
640
- val retryRunnable: Runnable
938
+ val retryRunnable: Runnable,
939
+ val sessionGeneration: Long
641
940
  )
642
941
 
643
942
  private var lc3AudioFileStream: FileOutputStream? = null
@@ -803,10 +1102,14 @@ class MentraLive : SGCManager() {
803
1102
  }
804
1103
 
805
1104
  private fun updateConnectionState(state: String) {
1105
+ if (state == ConnTypes.DISCONNECTED) {
1106
+ DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
1107
+ }
806
1108
  val isEqual = state == connectionState
807
1109
  if (isEqual) {
808
1110
  if (state == ConnTypes.DISCONNECTED) {
809
1111
  resetWireNegotiationState()
1112
+ resetPendingAckState()
810
1113
  // Queued writes are session-bound: transmitting them into the NEXT session
811
1114
  // (e.g. a stale handshake whose reply activates v2 before the new session
812
1115
  // negotiated) is the bug class this clear removes. Higher layers re-send
@@ -824,6 +1127,7 @@ class MentraLive : SGCManager() {
824
1127
  // same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
825
1128
  DeviceStore.apply("glasses", "serialNumber", "")
826
1129
  DeviceStore.apply("glasses", "bluetoothMacAddress", "")
1130
+ DeviceStore.apply("glasses", "wifiMacAddress", "")
827
1131
  }
828
1132
 
829
1133
  // Actually update the connection state!
@@ -854,6 +1158,7 @@ class MentraLive : SGCManager() {
854
1158
  DeviceStore.apply("glasses", "signalStrength", -1)
855
1159
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
856
1160
  resetWireNegotiationState()
1161
+ resetPendingAckState()
857
1162
  sendQueue.clear() // see the disconnect reset above: stale writes die with the session
858
1163
 
859
1164
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
@@ -874,6 +1179,17 @@ class MentraLive : SGCManager() {
874
1179
  cachedOtaCurrentStep = 0
875
1180
  cachedOtaStepSequence = null
876
1181
  lastBesOtaProgress = -1
1182
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
1183
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
1184
+ }
1185
+
1186
+ private fun updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
1187
+ besOtaHeartbeatGuard.observeOtaStatus(
1188
+ stepType,
1189
+ phase,
1190
+ status,
1191
+ SystemClock.elapsedRealtime(),
1192
+ )
877
1193
  }
878
1194
 
879
1195
  protected fun setFontSizes() {
@@ -884,6 +1200,10 @@ class MentraLive : SGCManager() {
884
1200
 
885
1201
  /** Starts BLE scanning for Mentra Live glasses */
886
1202
  private fun startScan() {
1203
+ if (pairingYieldActive) {
1204
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
1205
+ return
1206
+ }
887
1207
  if (bluetoothAdapter == null || isScanning) {
888
1208
  return
889
1209
  }
@@ -942,13 +1262,22 @@ class MentraLive : SGCManager() {
942
1262
  )
943
1263
  }
944
1264
 
1265
+ val generation = ++scanGeneration
945
1266
  isScanning = true
946
1267
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
1268
+ // Connected glasses stop advertising (BLE_CONNECTION_MAX=1). Pairing scan
1269
+ // would otherwise return empty even though GATT is already up.
1270
+ if (!isReconnecting) {
1271
+ handler.post { emitConnectedDeviceForPairingScan() }
1272
+ }
947
1273
 
948
1274
  // Set a timeout to stop scanning
949
1275
  handler.postDelayed(
950
1276
  object : Runnable {
951
1277
  override fun run() {
1278
+ if (generation != scanGeneration) {
1279
+ return
1280
+ }
952
1281
  if (isScanning) {
953
1282
  stopScan()
954
1283
  emitStopScanEvent()
@@ -992,11 +1321,13 @@ class MentraLive : SGCManager() {
992
1321
 
993
1322
  /** Stops BLE scanning */
994
1323
  override fun stopScan() {
1324
+ manualDiscoveryActive = false
995
1325
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
996
1326
  return
997
1327
  }
998
1328
 
999
1329
  try {
1330
+ scanGeneration++
1000
1331
  bluetoothScanner!!.stopScan(scanCallback)
1001
1332
  isScanning = false
1002
1333
  DeviceStore.apply("bluetooth", "searching", false)
@@ -1020,6 +1351,33 @@ class MentraLive : SGCManager() {
1020
1351
  Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
1021
1352
  }
1022
1353
 
1354
+ /**
1355
+ * Pairing scan listens for advertisements. A unit that is already GATT-connected
1356
+ * (typical after a CTKD retry / back-out on field firmware) has stopped ADV, so
1357
+ * emit it as pairable or the scan list stays empty.
1358
+ */
1359
+ private fun emitConnectedDeviceForPairingScan() {
1360
+ if (!isConnected || isReconnecting || pairingYieldActive) {
1361
+ return
1362
+ }
1363
+ val device = connectedDevice ?: bluetoothGatt?.device ?: return
1364
+ val name = safeBondedDeviceName(device)
1365
+ if (!isMentraLiveBluetoothName(name)) {
1366
+ return
1367
+ }
1368
+ Bridge.log(
1369
+ "LIVE: Pairing scan: already GATT-connected to $name — emitting as pairable (ADV off while connected)"
1370
+ )
1371
+ Bridge.sendDiscoveredDevice(
1372
+ DeviceTypes.LIVE,
1373
+ name,
1374
+ device.address ?: "",
1375
+ pairingMode = true,
1376
+ pairingCode = null,
1377
+ securePairingCapable = false,
1378
+ )
1379
+ }
1380
+
1023
1381
  var seenDevices: MutableSet<String> = HashSet()
1024
1382
 
1025
1383
  /** BLE Scan callback */
@@ -1072,42 +1430,77 @@ class MentraLive : SGCManager() {
1072
1430
  deviceName.startsWith("MENTRA_LIVE_BT") ||
1073
1431
  deviceName.lowercase().startsWith("mentra_live")
1074
1432
  ) {
1433
+ // Connect-by-name only when reconnecting OR connectById() asked for
1434
+ // this name. A discovery-only pairing scan must not auto-connect just
1435
+ // because savedDeviceName still matches (spoof risk).
1436
+ val isNamedConnectTarget =
1437
+ savedDeviceName.isNotEmpty() &&
1438
+ savedDeviceName == deviceName &&
1439
+ (isReconnecting || explicitConnectByName)
1440
+ if (!isReconnecting &&
1441
+ advertisesPairingFlag(result) &&
1442
+ !isPairingDiscoverable(result)
1443
+ ) {
1444
+ // Tell RN a nearby unit is advertising but not pairable so the
1445
+ // empty-state hint can fire. The scan list never shows these.
1446
+ Bridge.log(
1447
+ "LIVE: Nearby $deviceName is secure firmware not in pairing mode — hiding from scan list"
1448
+ )
1449
+ Bridge.sendDiscoveredDevice(
1450
+ DeviceTypes.LIVE,
1451
+ deviceName,
1452
+ deviceAddress,
1453
+ result.rssi,
1454
+ pairingMode = false,
1455
+ pairingCode = null,
1456
+ securePairingCapable = true,
1457
+ )
1458
+ return
1459
+ }
1075
1460
  val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
1461
+ val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
1076
1462
  Bridge.log(
1077
1463
  "LIVE: Found compatible " +
1078
1464
  glassType +
1079
1465
  " glasses device: " +
1080
- deviceName
1466
+ deviceName +
1467
+ if (secureCapable) " (pairingMode=$pairingMode)"
1468
+ else " (legacy firmware, pairable)"
1081
1469
  )
1082
- // EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
1083
- // smartGlassesDevice.deviceModelName, deviceName));
1084
1470
  Bridge.sendDiscoveredDevice(
1085
1471
  DeviceTypes.LIVE,
1086
1472
  deviceName,
1087
1473
  deviceAddress,
1088
- result.rssi
1474
+ result.rssi,
1475
+ // Field firmware has no pairing flag. Treat it as pairable so the
1476
+ // new Mentra App can still find old glasses.
1477
+ pairingMode = if (secureCapable) pairingMode else true,
1478
+ pairingCode = pairingCode,
1479
+ securePairingCapable = secureCapable,
1089
1480
  )
1090
1481
 
1091
- // If this is the specific device we want to connect to by name, connect to
1092
- // it
1093
- if (savedDeviceName != null && savedDeviceName == deviceName) {
1482
+ // connectById / reconnect: user (or reconnect) asked for this name
1483
+ // GATT-connect. Discovery-only scans never set explicitConnectByName.
1484
+ if (isNamedConnectTarget) {
1094
1485
  Log.i(
1095
1486
  TAG,
1096
- "🔌 🎯 RECONNECT TARGET FOUND - Device: " +
1487
+ "🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
1097
1488
  deviceName +
1098
1489
  " (Attempt #" +
1099
1490
  reconnectAttempts +
1100
1491
  ")"
1101
1492
  )
1102
1493
  Bridge.log(
1103
- "LIVE: 🔌 🎯 Found our remembered device by name, connecting: " +
1494
+ "LIVE: 🔌 🎯 Found target device by name, connecting: " +
1104
1495
  deviceName +
1105
- " (Reconnect attempt #" +
1106
- reconnectAttempts +
1107
- ")"
1496
+ (if (isReconnecting)
1497
+ " (Reconnect attempt #" +
1498
+ reconnectAttempts +
1499
+ ")"
1500
+ else " (connectById)")
1108
1501
  )
1109
1502
  synchronized(connectionLock) {
1110
- if (isConnected || isConnecting) {
1503
+ if (pairingYieldActive || isConnected || isConnecting) {
1111
1504
  return
1112
1505
  }
1113
1506
  isConnecting = true
@@ -1115,6 +1508,7 @@ class MentraLive : SGCManager() {
1115
1508
  stopScan()
1116
1509
  emitStopScanEvent()
1117
1510
  isReconnecting = false
1511
+ explicitConnectByName = false
1118
1512
  connectToDevice(device)
1119
1513
  }
1120
1514
  }
@@ -1176,11 +1570,67 @@ class MentraLive : SGCManager() {
1176
1570
  }
1177
1571
  }
1178
1572
 
1573
+ /**
1574
+ * Reject callbacks delivered by a GATT object from an earlier connection attempt. Without
1575
+ * this identity check, a late disconnect can tear down the active connection and clear its
1576
+ * session-bound send state.
1577
+ */
1578
+ private fun isCurrentGattCallback(gatt: BluetoothGatt): Boolean {
1579
+ if (gatt === bluetoothGatt) {
1580
+ return true
1581
+ }
1582
+
1583
+ Log.w(TAG, "🔌 Ignoring callback from stale BluetoothGatt")
1584
+ Bridge.log("LIVE: 🔌 Ignoring callback from stale BluetoothGatt session")
1585
+ try {
1586
+ gatt.close()
1587
+ } catch (e: Exception) {
1588
+ Log.w(TAG, "🔌 Failed to close stale BluetoothGatt: " + e)
1589
+ }
1590
+ return false
1591
+ }
1592
+
1593
+ private fun beginPairingTiming(reason: String) {
1594
+ pairingTimingStartMs = SystemClock.elapsedRealtime()
1595
+ pairingTimingLastMs = pairingTimingStartMs
1596
+ pairingTimingActive = true
1597
+ Bridge.log("LIVE: PAIRING_TIMING begin reason=$reason sinceStartMs=0 deltaMs=0")
1598
+ }
1599
+
1600
+ private fun markPairingTiming(checkpoint: String, detail: String = "") {
1601
+ if (!pairingTimingActive) {
1602
+ return
1603
+ }
1604
+ val now = SystemClock.elapsedRealtime()
1605
+ val sinceStart = now - pairingTimingStartMs
1606
+ val delta = now - pairingTimingLastMs
1607
+ pairingTimingLastMs = now
1608
+ val extra = if (detail.isEmpty()) "" else " $detail"
1609
+ Bridge.log(
1610
+ "LIVE: PAIRING_TIMING checkpoint=$checkpoint sinceStartMs=$sinceStart deltaMs=$delta$extra"
1611
+ )
1612
+ }
1613
+
1614
+ private fun endPairingTiming(checkpoint: String, detail: String = "") {
1615
+ markPairingTiming(checkpoint, detail)
1616
+ pairingTimingActive = false
1617
+ }
1618
+
1179
1619
  /** Connect to a specific BLE device */
1180
1620
  private fun connectToDevice(device: BluetoothDevice?) {
1181
1621
  if (device == null) {
1182
1622
  return
1183
1623
  }
1624
+ if (pairingYieldActive) {
1625
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1626
+ isConnecting = false
1627
+ isReconnecting = false
1628
+ return
1629
+ }
1630
+
1631
+ beginPairingTiming(
1632
+ "connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
1633
+ )
1184
1634
 
1185
1635
  // Cancel any previous connection timeouts
1186
1636
  if (connectionTimeoutRunnable != null) {
@@ -1280,6 +1730,85 @@ class MentraLive : SGCManager() {
1280
1730
  // }
1281
1731
  // }
1282
1732
 
1733
+ private fun enterPairingYield(windowMs: Long) {
1734
+ pairingYieldActive = true
1735
+ pairingYieldAwaitingReclaim = false
1736
+ isReconnecting = false
1737
+ isConnecting = false
1738
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1739
+ if (connectionTimeoutRunnable != null) {
1740
+ connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
1741
+ connectionTimeoutRunnable = null
1742
+ }
1743
+ // Stop scans/reconnect work; keep default_wearable / Classic bond.
1744
+ if (isScanning) {
1745
+ stopScan()
1746
+ emitStopScanEvent()
1747
+ }
1748
+ // Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
1749
+ // Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
1750
+ val yieldingDevice = connectedDevice
1751
+ disconnectClassicProfiles(yieldingDevice)
1752
+ // closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
1753
+ // onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
1754
+ // Mirror a real disconnect for UI + session state, then tear GATT down.
1755
+ isConnected = false
1756
+ connectedDevice = null
1757
+ glassesReady = false
1758
+ glassesSessionId = null
1759
+ readinessCompletedThisBleSession = false
1760
+ glassesReadyReceived = false
1761
+ ctkdInitiatedThisGattSession = false
1762
+ audioConnected = false
1763
+ notificationsEnabled = false
1764
+ processSendQueueRunnable?.let { handler.removeCallbacks(it) }
1765
+ stopReadinessCheckLoop()
1766
+ stopHeartbeat()
1767
+ stopSignalStrengthPolling()
1768
+ stopMicBeat()
1769
+ closeL2capFileChannel()
1770
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1771
+ clearFilePacketBuffer()
1772
+ updateConnectionState(ConnTypes.DISCONNECTED)
1773
+ closeGattQuietly(true)
1774
+ // Stand down for the whole window. Probing GATT during yield races the new
1775
+ // phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
1776
+ val end =
1777
+ Runnable {
1778
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1779
+ pairingYieldActive = false
1780
+ pairingYieldAwaitingReclaim = true
1781
+ pairingYieldEndRunnable = null
1782
+ if (!isKilled && !isConnected && !isConnecting) {
1783
+ handleReconnection()
1784
+ }
1785
+ }
1786
+ pairingYieldEndRunnable = end
1787
+ handler.postDelayed(end, windowMs)
1788
+ }
1789
+
1790
+ fun isPairingYieldActive(): Boolean = pairingYieldActive
1791
+
1792
+ private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
1793
+ Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
1794
+ pairingYieldAwaitingReclaim = false
1795
+ pairingYieldActive = false
1796
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1797
+ pairingYieldEndRunnable = null
1798
+ try {
1799
+ forget()
1800
+ } catch (e: Exception) {
1801
+ Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
1802
+ }
1803
+ DeviceStore.apply("bluetooth", "default_wearable", "")
1804
+ DeviceStore.apply("bluetooth", "device_name", "")
1805
+ DeviceStore.apply("bluetooth", "device_address", "")
1806
+ Bridge.saveSetting("default_wearable", "")
1807
+ Bridge.saveSetting("device_name", "")
1808
+ Bridge.saveSetting("device_address", "")
1809
+ Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
1810
+ }
1811
+
1283
1812
  /** Handle reconnection with exponential backoff */
1284
1813
  private fun handleReconnection() {
1285
1814
  // Don't attempt reconnection if we've been killed/forgotten
@@ -1289,6 +1818,20 @@ class MentraLive : SGCManager() {
1289
1818
  return
1290
1819
  }
1291
1820
 
1821
+ if (manualDiscoveryActive) {
1822
+ Bridge.log(
1823
+ "LIVE: 🔌 RECONNECT ABORTED - Pair Glasses discovery is active"
1824
+ )
1825
+ isReconnecting = false
1826
+ return
1827
+ }
1828
+
1829
+ if (pairingYieldActive) {
1830
+ Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
1831
+ isReconnecting = false
1832
+ return
1833
+ }
1834
+
1292
1835
  if (isReconnecting) {
1293
1836
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
1294
1837
  return
@@ -1360,10 +1903,25 @@ class MentraLive : SGCManager() {
1360
1903
  "ms)"
1361
1904
  )
1362
1905
 
1363
- // Schedule reconnection attempt
1906
+ // Schedule reconnection attempt. A later manual scan/connect invalidates this
1907
+ // callback so stale saved identity cannot hijack user-initiated discovery.
1908
+ val generation = reconnectGeneration
1364
1909
  handler.postDelayed(
1365
1910
  object : Runnable {
1366
1911
  override fun run() {
1912
+ if (generation != reconnectGeneration) {
1913
+ Bridge.log(
1914
+ "LIVE: 🔌 RECONNECT ABORTED - superseded by manual discovery"
1915
+ )
1916
+ return
1917
+ }
1918
+ if (pairingYieldActive) {
1919
+ isReconnecting = false
1920
+ Bridge.log(
1921
+ "LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
1922
+ )
1923
+ return
1924
+ }
1367
1925
  if (!isConnected && !isConnecting && !isKilled) {
1368
1926
  // Prefer saved MAC for direct GATT connect (faster and more reliable
1369
1927
  // than scanning).
@@ -1498,6 +2056,10 @@ class MentraLive : SGCManager() {
1498
2056
  status: Int,
1499
2057
  newState: Int
1500
2058
  ) {
2059
+ if (!isCurrentGattCallback(gatt)) {
2060
+ return
2061
+ }
2062
+
1501
2063
  // Cancel the connection timeout
1502
2064
  if (connectionTimeoutRunnable != null) {
1503
2065
  connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
@@ -1509,6 +2071,7 @@ class MentraLive : SGCManager() {
1509
2071
  Bridge.log(
1510
2072
  "LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
1511
2073
  )
2074
+ markPairingTiming("gatt_connected")
1512
2075
  isConnecting = false
1513
2076
  isConnected = true
1514
2077
  connectedDevice = gatt.device
@@ -1536,42 +2099,27 @@ class MentraLive : SGCManager() {
1536
2099
  // connectedDevice.getName());
1537
2100
  // }
1538
2101
 
1539
- // CTKD Implementation: Register bonding receiver and create bond for BT
1540
- // Classic
2102
+ // Register early so we don't miss bond broadcasts, but defer
2103
+ // createBond / A2DP until BLE GATT setup finishes (see
2104
+ // maybeInitiateCtkdAudioAfterBleReady).
1541
2105
  registerBondingReceiver()
1542
- bondingRetryCount = 0 // Reset retry counter for new connection
2106
+ bondingRetryCount = 0
2107
+ ctkdInitiatedThisGattSession = false
1543
2108
  Bridge.log(
1544
- "LIVE: CTKD: BLE connection established, initiating CTKD bonding for BT Classic"
2109
+ "LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
2110
+ )
2111
+ markPairingTiming(
2112
+ "ctkd_deferred",
2113
+ "bondState=${connectedDevice!!.bondState}"
1545
2114
  )
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
2115
 
1562
2116
  // Discover services
1563
2117
  gatt.discoverServices()
1564
2118
 
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
- )
2119
+ // Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
2120
+ // followed by status 19 was zeroing the counter every ~1s and
2121
+ // preventing exponential backoff. Reset at ble_chars_ready instead.
2122
+ isReconnecting = false
1575
2123
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1576
2124
  Log.w(
1577
2125
  TAG,
@@ -1580,6 +2128,10 @@ class MentraLive : SGCManager() {
1580
2128
  Bridge.log(
1581
2129
  "LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
1582
2130
  )
2131
+ endPairingTiming(
2132
+ "gatt_disconnected",
2133
+ "queueSize=${sendQueue.size}"
2134
+ )
1583
2135
  isConnected = false
1584
2136
  isConnecting = false
1585
2137
 
@@ -1590,6 +2142,7 @@ class MentraLive : SGCManager() {
1590
2142
 
1591
2143
  // Reset audio pairing flags
1592
2144
  glassesReadyReceived = false
2145
+ ctkdInitiatedThisGattSession = false
1593
2146
  audioConnected = false
1594
2147
 
1595
2148
  notificationsEnabled = false
@@ -1648,6 +2201,10 @@ class MentraLive : SGCManager() {
1648
2201
  status +
1649
2202
  ") - Will retry reconnection"
1650
2203
  )
2204
+ endPairingTiming(
2205
+ "gatt_error",
2206
+ "status=$status queueSize=${sendQueue.size}"
2207
+ )
1651
2208
  isConnected = false
1652
2209
  isConnecting = false
1653
2210
  glassesReady = false
@@ -1655,6 +2212,7 @@ class MentraLive : SGCManager() {
1655
2212
  readinessCompletedThisBleSession = false
1656
2213
  glassesReadyReceived = false
1657
2214
  audioConnected = false
2215
+ ctkdInitiatedThisGattSession = false
1658
2216
 
1659
2217
  notificationsEnabled = false
1660
2218
 
@@ -1678,6 +2236,19 @@ class MentraLive : SGCManager() {
1678
2236
  // Clean up resources
1679
2237
  closeGattQuietly(false)
1680
2238
 
2239
+ // Auth/not-owner after pairing yield — not a generic RF blip.
2240
+ // GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
2241
+ // not wipe the saved owner.
2242
+ if (pairingYieldAwaitingReclaim &&
2243
+ (status == 5 ||
2244
+ status == 15 ||
2245
+ status == 0x05 ||
2246
+ status == 0x0F)
2247
+ ) {
2248
+ clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
2249
+ return
2250
+ }
2251
+
1681
2252
  // Attempt reconnection if not killed
1682
2253
  if (!isKilled) {
1683
2254
  Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
@@ -1728,24 +2299,47 @@ class MentraLive : SGCManager() {
1728
2299
  txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
1729
2300
  rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
1730
2301
 
1731
- // Get LC3 characteristics (always supported)
2302
+ // LC3 audio is optional. Older firmware can still run the core session
2303
+ // and receive an OTA without these characteristics.
1732
2304
  lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
1733
2305
  lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
1734
2306
 
1735
- // Check if we have required characteristics
2307
+ // Match iOS: only the core transport pair is required for readiness.
1736
2308
  val hasRequiredCharacteristics =
1737
- (rxCharacteristic != null && txCharacteristic != null) &&
1738
- (lc3ReadCharacteristic != null &&
1739
- lc3WriteCharacteristic != null)
2309
+ hasRequiredMentraLiveCoreCharacteristics(
2310
+ hasRxCharacteristic = rxCharacteristic != null,
2311
+ hasTxCharacteristic = txCharacteristic != null,
2312
+ )
2313
+ val hasLc3AudioCharacteristics =
2314
+ lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
1740
2315
 
1741
2316
  if (hasRequiredCharacteristics) {
2317
+ if (!hasLc3AudioCharacteristics) {
2318
+ Bridge.log(
2319
+ "LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
2320
+ )
2321
+ }
1742
2322
  // BLE connection established, but we still need to wait for glasses
1743
2323
  // SOC
1744
2324
  Bridge.log(
1745
- "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX + LC3 TX/RX characteristics verified)"
2325
+ "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
2326
+ hasLc3AudioCharacteristics +
2327
+ ")"
1746
2328
  )
2329
+ markPairingTiming("ble_chars_ready")
1747
2330
  Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1748
2331
 
2332
+ // GATT stack reached real readiness — safe to clear reconnect
2333
+ // backoff.
2334
+ val previousAttempts = reconnectAttempts
2335
+ reconnectAttempts = 0
2336
+ Log.i(
2337
+ TAG,
2338
+ "🔌 ✅ Reconnection counter reset at ble_chars_ready (was at " +
2339
+ previousAttempts +
2340
+ " attempts)"
2341
+ )
2342
+
1749
2343
  // Don't set connected=true here - wait for SOC to be ready
1750
2344
  // (fullyBooted=true)
1751
2345
  // DeviceStore handles connected state based on fullyBooted
@@ -1786,13 +2380,6 @@ class MentraLive : SGCManager() {
1786
2380
  if (txCharacteristic == null) {
1787
2381
  Log.e(TAG, "TX characteristic (peripheral's RX) not found")
1788
2382
  }
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
2383
  gatt.disconnect()
1797
2384
  }
1798
2385
  } else {
@@ -1899,6 +2486,10 @@ class MentraLive : SGCManager() {
1899
2486
  gatt: BluetoothGatt,
1900
2487
  characteristic: BluetoothGattCharacteristic
1901
2488
  ) {
2489
+ if (!isCurrentGattCallback(gatt)) {
2490
+ return
2491
+ }
2492
+
1902
2493
  // Get thread ID for tracking thread issues
1903
2494
  val threadId = Thread.currentThread().id
1904
2495
  val uuid = characteristic.uuid
@@ -1932,20 +2523,6 @@ class MentraLive : SGCManager() {
1932
2523
 
1933
2524
  if (isRxCharacteristic) {
1934
2525
  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
2526
  } else if (isTxCharacteristic) {
1950
2527
  Bridge.log("LIVE: Received data on TX characteristic")
1951
2528
  } else if (isLc3ReadCharacteristic) {
@@ -2050,6 +2627,9 @@ class MentraLive : SGCManager() {
2050
2627
  startSignalStrengthPolling()
2051
2628
  handler.post(processSendQueueRunnable!!)
2052
2629
  startReadinessCheckLoop()
2630
+ // Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
2631
+ // collide during service discovery / descriptor writes.
2632
+ maybeInitiateCtkdAudioAfterBleReady()
2053
2633
  return
2054
2634
  }
2055
2635
 
@@ -2310,8 +2890,20 @@ class MentraLive : SGCManager() {
2310
2890
  return
2311
2891
  }
2312
2892
 
2313
- // Send the next item from the queue
2314
- val queuedWrite = sendQueue.poll()
2893
+ // Send the next item from this BLE session. A retry can race disconnect teardown and land
2894
+ // after sendQueue.clear(); the generation check is the deterministic backstop.
2895
+ var queuedWrite = sendQueue.poll()
2896
+ val currentGeneration = bleSessionGeneration.get()
2897
+ while (queuedWrite != null && queuedWrite.sessionGeneration != currentGeneration) {
2898
+ Bridge.log(
2899
+ "LIVE: Dropping stale queued write from BLE session " +
2900
+ queuedWrite.sessionGeneration +
2901
+ " (current: " +
2902
+ currentGeneration +
2903
+ ")"
2904
+ )
2905
+ queuedWrite = sendQueue.poll()
2906
+ }
2315
2907
  if (queuedWrite != null) {
2316
2908
  // Update last send time before sending
2317
2909
  lastSendTimeMs = currentTimeMs
@@ -2338,25 +2930,35 @@ class MentraLive : SGCManager() {
2338
2930
 
2339
2931
  /** Send data through BLE */
2340
2932
  private fun sendDataInternal(write: QueuedBleWrite?) {
2341
- if (!isConnected || bluetoothGatt == null || txCharacteristic == null || write == null) {
2933
+ if (!isConnected || write == null) {
2934
+ return
2935
+ }
2936
+ if (write.sessionGeneration != bleSessionGeneration.get()) {
2937
+ Bridge.log("LIVE: Dropping stale BLE write before GATT transmission")
2938
+ return
2939
+ }
2940
+
2941
+ val gatt = bluetoothGatt
2942
+ val characteristic = txCharacteristic
2943
+ if (gatt == null || characteristic == null) {
2342
2944
  return
2343
2945
  }
2344
2946
 
2345
2947
  try {
2346
2948
  val writeStartedAtMs = System.currentTimeMillis()
2347
- txCharacteristic!!.value = write.data
2949
+ characteristic.value = write.data
2348
2950
  inFlightBleWriteTrace = write.trace
2349
2951
  inFlightBleWriteStartedAtMs = writeStartedAtMs
2350
- val writeAccepted = bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2952
+ val writeAccepted = gatt.writeCharacteristic(characteristic)
2351
2953
  logBleWriteTrace(
2352
2954
  "write_call",
2353
2955
  write.trace,
2354
2956
  mapOf(
2355
2957
  "writeAccepted" to writeAccepted,
2356
2958
  "currentMtu" to currentMtu,
2357
- "writeType" to txCharacteristic!!.writeType,
2959
+ "writeType" to characteristic.writeType,
2358
2960
  "queueSize" to sendQueue.size,
2359
- "characteristicUuid" to txCharacteristic!!.uuid.toString()
2961
+ "characteristicUuid" to characteristic.uuid.toString()
2360
2962
  )
2361
2963
  )
2362
2964
  if (!writeAccepted) {
@@ -2379,7 +2981,11 @@ class MentraLive : SGCManager() {
2379
2981
  }
2380
2982
 
2381
2983
  /** Queue data to be sent */
2382
- private fun queueData(data: ByteArray?, trace: BleWriteTrace? = null) {
2984
+ private fun queueData(
2985
+ data: ByteArray?,
2986
+ trace: BleWriteTrace? = null,
2987
+ sessionGeneration: Long = bleSessionGeneration.get()
2988
+ ) {
2383
2989
  if (data != null) {
2384
2990
  val queuedTrace =
2385
2991
  trace?.copy(
@@ -2388,7 +2994,7 @@ class MentraLive : SGCManager() {
2388
2994
  trace.queuedAtMs
2389
2995
  else System.currentTimeMillis()
2390
2996
  )
2391
- sendQueue.add(QueuedBleWrite(data, queuedTrace))
2997
+ sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
2392
2998
  logBleChunkTrace(
2393
2999
  "queued",
2394
3000
  queuedTrace,
@@ -2432,6 +3038,7 @@ class MentraLive : SGCManager() {
2432
3038
  private fun sendJson(json: JSONObject?, wakeup: Boolean) {
2433
3039
  if (json != null) {
2434
3040
  try {
3041
+ val sessionGeneration = bleSessionGeneration.get()
2435
3042
  if (buildNumberInt < 5) {
2436
3043
  val jsonStr = json.toString()
2437
3044
  // Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
@@ -2447,7 +3054,7 @@ class MentraLive : SGCManager() {
2447
3054
  json,
2448
3055
  jsonStr.length
2449
3056
  )
2450
- sendDataToGlasses(jsonStr, wakeup)
3057
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2451
3058
  } else {
2452
3059
  // Add esoteric message ID to the JSON
2453
3060
  val messageId = generateEsotericMessageId()
@@ -2489,7 +3096,7 @@ class MentraLive : SGCManager() {
2489
3096
  }
2490
3097
 
2491
3098
  // Track the message for ACK with appropriate timeout
2492
- trackMessageForAck(messageId, jsonStr, ackTimeout)
3099
+ trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
2493
3100
 
2494
3101
  // Send the data
2495
3102
  if ("take_photo" == json.optString("type", "")) {
@@ -2508,7 +3115,7 @@ class MentraLive : SGCManager() {
2508
3115
  json,
2509
3116
  jsonStr.length
2510
3117
  )
2511
- sendDataToGlasses(jsonStr, wakeup)
3118
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2512
3119
  }
2513
3120
  } catch (e: JSONException) {
2514
3121
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -2528,14 +3135,14 @@ class MentraLive : SGCManager() {
2528
3135
  return list
2529
3136
  }
2530
3137
 
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
3138
  /** Track a message for ACK response with custom timeout */
2537
- private fun trackMessageForAck(messageId: Long, messageData: String, timeoutMs: Long) {
2538
- if (!isConnected) {
3139
+ private fun trackMessageForAck(
3140
+ messageId: Long,
3141
+ messageData: String,
3142
+ timeoutMs: Long,
3143
+ sessionGeneration: Long
3144
+ ) {
3145
+ if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
2539
3146
  Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
2540
3147
  return
2541
3148
  }
@@ -2552,25 +3159,50 @@ class MentraLive : SGCManager() {
2552
3159
  }
2553
3160
 
2554
3161
  // Create retry runnable
2555
- val retryRunnable = Runnable { retryMessage(messageId) }
3162
+ val retryRunnable = Runnable { retryMessage(messageId, sessionGeneration) }
2556
3163
 
2557
3164
  // Create pending message
2558
3165
  val pendingMessage =
2559
- PendingMessage(messageData, System.currentTimeMillis(), 0, retryRunnable)
3166
+ PendingMessage(
3167
+ messageData,
3168
+ System.currentTimeMillis(),
3169
+ 0,
3170
+ retryRunnable,
3171
+ sessionGeneration
3172
+ )
2560
3173
  pendingMessages[messageId] = pendingMessage
2561
3174
 
2562
3175
  // Schedule ACK timeout with custom timeout
2563
- handler.postDelayed({ checkMessageAck(messageId) }, timeoutMs)
3176
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
2564
3177
 
2565
3178
  Bridge.log(
2566
3179
  "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
2567
3180
  )
2568
3181
  }
2569
3182
 
3183
+ /**
3184
+ * Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
3185
+ * the old generation, and queued retries retain it, so a retry racing this clear cannot become
3186
+ * valid in the next session.
3187
+ */
3188
+ private fun resetPendingAckState() {
3189
+ val nextGeneration = bleSessionGeneration.incrementAndGet()
3190
+ val pendingCount = pendingMessages.size
3191
+ pendingMessages.clear()
3192
+ if (pendingCount > 0) {
3193
+ Bridge.log(
3194
+ "LIVE: Cleared $pendingCount pending ACK message(s); BLE session is now $nextGeneration"
3195
+ )
3196
+ }
3197
+ }
3198
+
2570
3199
  /** Check if a message has been acknowledged */
2571
- private fun checkMessageAck(messageId: Long) {
3200
+ private fun checkMessageAck(messageId: Long, sessionGeneration: Long) {
2572
3201
  val pendingMessage = pendingMessages[messageId]
2573
- if (pendingMessage != null) {
3202
+ if (pendingMessage != null &&
3203
+ pendingMessage.sessionGeneration == sessionGeneration &&
3204
+ sessionGeneration == bleSessionGeneration.get()
3205
+ ) {
2574
3206
  Log.w(
2575
3207
  TAG,
2576
3208
  "⏰ ACK timeout for message " +
@@ -2591,7 +3223,7 @@ class MentraLive : SGCManager() {
2591
3223
  MAX_RETRY_ATTEMPTS +
2592
3224
  ")"
2593
3225
  )
2594
- retryMessage(messageId)
3226
+ retryMessage(messageId, sessionGeneration)
2595
3227
  } else {
2596
3228
  // Max retries reached
2597
3229
  Log.e(
@@ -2602,22 +3234,31 @@ class MentraLive : SGCManager() {
2602
3234
  MAX_RETRY_ATTEMPTS +
2603
3235
  " attempts"
2604
3236
  )
2605
- pendingMessages.remove(messageId)
3237
+ pendingMessages.remove(messageId, pendingMessage)
2606
3238
  }
3239
+ } else if (pendingMessage != null && pendingMessage.sessionGeneration == sessionGeneration) {
3240
+ pendingMessages.remove(messageId, pendingMessage)
2607
3241
  }
2608
3242
  }
2609
3243
 
2610
3244
  /** Retry a message */
2611
- private fun retryMessage(messageId: Long) {
3245
+ private fun retryMessage(messageId: Long, sessionGeneration: Long) {
2612
3246
  val pendingMessage = pendingMessages[messageId]
2613
3247
  if (pendingMessage == null) {
2614
3248
  Log.w(TAG, "Message " + messageId + " no longer tracked for retry")
2615
3249
  return
2616
3250
  }
3251
+ if (pendingMessage.sessionGeneration != sessionGeneration ||
3252
+ sessionGeneration != bleSessionGeneration.get()
3253
+ ) {
3254
+ pendingMessages.remove(messageId, pendingMessage)
3255
+ Bridge.log("LIVE: Dropping stale ACK retry for message $messageId")
3256
+ return
3257
+ }
2617
3258
 
2618
3259
  if (pendingMessage.retryCount >= MAX_RETRY_ATTEMPTS) {
2619
3260
  Log.e(TAG, "Max retries reached for message " + messageId)
2620
- pendingMessages.remove(messageId)
3261
+ pendingMessages.remove(messageId, pendingMessage)
2621
3262
  return
2622
3263
  }
2623
3264
 
@@ -2627,11 +3268,18 @@ class MentraLive : SGCManager() {
2627
3268
  pendingMessage.messageData,
2628
3269
  System.currentTimeMillis(),
2629
3270
  pendingMessage.retryCount + 1,
2630
- pendingMessage.retryRunnable
3271
+ pendingMessage.retryRunnable,
3272
+ sessionGeneration
2631
3273
  )
2632
3274
 
2633
- // Update the tracked message
2634
- pendingMessages[messageId] = retryMessage
3275
+ // Replace only the entry inspected above. An ACK or disconnect may have removed it.
3276
+ if (!pendingMessages.replace(messageId, pendingMessage, retryMessage)) {
3277
+ return
3278
+ }
3279
+ if (sessionGeneration != bleSessionGeneration.get()) {
3280
+ pendingMessages.remove(messageId, retryMessage)
3281
+ return
3282
+ }
2635
3283
 
2636
3284
  // Send the message again
2637
3285
  Bridge.log(
@@ -2641,10 +3289,10 @@ class MentraLive : SGCManager() {
2641
3289
  retryMessage.retryCount +
2642
3290
  ")"
2643
3291
  )
2644
- sendDataToGlasses(pendingMessage.messageData, false)
3292
+ sendDataToGlasses(pendingMessage.messageData, false, sessionGeneration)
2645
3293
 
2646
3294
  // Schedule next ACK check
2647
- handler.postDelayed({ checkMessageAck(messageId) }, ACK_TIMEOUT_MS)
3295
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, ACK_TIMEOUT_MS)
2648
3296
  }
2649
3297
 
2650
3298
  /** Process ACK response from glasses */
@@ -2955,25 +3603,6 @@ class MentraLive : SGCManager() {
2955
3603
  processJsonMessage(expanded)
2956
3604
  } else {
2957
3605
  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
3606
  }
2978
3607
 
2979
3608
  return // Exit after processing K900 protocol format
@@ -3147,6 +3776,24 @@ class MentraLive : SGCManager() {
3147
3776
  "pong" ->
3148
3777
  // Process heartbeat pong response
3149
3778
  Bridge.log("LIVE: Received pong response - connection healthy")
3779
+ "entering_pairing_mode" -> {
3780
+ val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
3781
+ Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
3782
+ enterPairingYield(windowMs)
3783
+ val body = HashMap<String, Any>()
3784
+ body["window_ms"] = windowMs
3785
+ body["reason"] = json.optString("reason", "user_gesture")
3786
+ Bridge.sendTypedMessage("entering_pairing_mode", body)
3787
+ }
3788
+ "pairing_info" ->
3789
+ Bridge.sendPairingInfo(
3790
+ json.optBoolean("had_previous_bond", false),
3791
+ jsonNullableString(json, "pairing_code"),
3792
+ json.optBoolean("classic_bond_ready", false),
3793
+ // Legacy firmware that omits this field is not secure-capable.
3794
+ json.optBoolean("secure_pairing_capable", false),
3795
+ json.optInt("protocol_version", 1),
3796
+ )
3150
3797
  "imu_response",
3151
3798
  "imu_stream_response",
3152
3799
  "imu_gesture_response",
@@ -3328,6 +3975,7 @@ class MentraLive : SGCManager() {
3328
3975
  if (osStepSequence != null && osStepSequence.length() > 0) {
3329
3976
  cachedOtaStepSequence = osStepSequence
3330
3977
  }
3978
+ updateBesOtaHeartbeatGuard(osStepType, osPhase, osStatus)
3331
3979
 
3332
3980
  Bridge.log(
3333
3981
  "LIVE: 📱 OTA status - step " +
@@ -3378,6 +4026,7 @@ class MentraLive : SGCManager() {
3378
4026
  } else {
3379
4027
  unified = "in_progress"
3380
4028
  }
4029
+ updateBesOtaHeartbeatGuard(currentUpdate, legacyPhase, unified)
3381
4030
  Bridge.log(
3382
4031
  "LIVE: 📱 Legacy ota_progress → ota_status: " +
3383
4032
  legacyStage +
@@ -3533,7 +4182,19 @@ class MentraLive : SGCManager() {
3533
4182
  }
3534
4183
  "glasses_ready" -> {
3535
4184
  // Glasses SOC has booted and is ready for communication
4185
+ if (pairingYieldActive && !isConnected) {
4186
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
4187
+ return
4188
+ }
3536
4189
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
4190
+ pairingYieldAwaitingReclaim = false
4191
+ pairingYieldActive = false
4192
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
4193
+ pairingYieldEndRunnable = null
4194
+ markPairingTiming(
4195
+ "glasses_ready",
4196
+ "audioConnected=$audioConnected queueSize=${sendQueue.size}"
4197
+ )
3537
4198
 
3538
4199
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
3539
4200
  // onTransportReset() before sending it, so their side is back on legacy
@@ -3552,9 +4213,9 @@ class MentraLive : SGCManager() {
3552
4213
  // Set the ready flag to stop any future readiness checks
3553
4214
  glassesReady = true
3554
4215
  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.
4216
+ // Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
4217
+ // fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
4218
+ // for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
3558
4219
 
3559
4220
  // Stop the readiness check loop since we got confirmation
3560
4221
  stopReadinessCheckLoop()
@@ -3674,19 +4335,21 @@ class MentraLive : SGCManager() {
3674
4335
  Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
3675
4336
  }
3676
4337
 
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) {
4338
+ // Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
4339
+ // Do not wait for CTKD here iOS already marks ready on glasses_ready alone.
4340
+ endPairingTiming(
4341
+ "fully_connected",
4342
+ "via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
4343
+ )
4344
+ DeviceStore.apply("glasses", "fullyBooted", true)
4345
+ updateConnectionState(ConnTypes.CONNECTED)
4346
+ if (!audioConnected) {
3682
4347
  Bridge.log(
3683
- "LIVE: Audio: Both glasses_ready and audio connected - marking as fully connected"
4348
+ "LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
3684
4349
  )
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"
4350
+ markPairingTiming(
4351
+ "waiting_ctkd_bond",
4352
+ "queueSize=${sendQueue.size}"
3690
4353
  )
3691
4354
  }
3692
4355
  }
@@ -4000,6 +4663,9 @@ class MentraLive : SGCManager() {
4000
4663
  (fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4001
4664
  DeviceStore.apply("glasses", "bluetoothMacAddress", it)
4002
4665
  }
4666
+ (fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4667
+ DeviceStore.apply("glasses", "wifiMacAddress", it)
4668
+ }
4003
4669
  (fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4004
4670
  DeviceStore.apply("glasses", "serialNumber", it)
4005
4671
  }
@@ -4009,6 +4675,12 @@ class MentraLive : SGCManager() {
4009
4675
  DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
4010
4676
  }
4011
4677
  }
4678
+ if (fields.containsKey("hotspot_ota_version")) {
4679
+ val version = fields["hotspot_ota_version"]
4680
+ if (version is Number) {
4681
+ DeviceStore.apply("glasses", "hotspotOtaVersion", version.toInt())
4682
+ }
4683
+ }
4012
4684
 
4013
4685
  // Negotiate wire caps from ANY version_info chunk, not only the one
4014
4686
  // carrying build_number: the glasses put wire_caps in version_info_3
@@ -4365,6 +5037,10 @@ class MentraLive : SGCManager() {
4365
5037
  }
4366
5038
  if (ready == 1) {
4367
5039
  Bridge.log("LIVE: K900 SOC ready")
5040
+ markPairingTiming(
5041
+ "soc_ready",
5042
+ "glassesReady=$glassesReady queueSize=${sendQueue.size}"
5043
+ )
4368
5044
  // Only send phone_ready if we haven't already established connection
4369
5045
  // This prevents re-initialization on every heartbeat after initial
4370
5046
  // connection
@@ -4468,15 +5144,28 @@ class MentraLive : SGCManager() {
4468
5144
  var progress = ((rawProgress + 2) / 5) * 5
4469
5145
  if (progress > 100) progress = 100
4470
5146
 
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
5147
+ val mapping =
5148
+ mapBesOtaProgress(
5149
+ type,
5150
+ rawProgress,
5151
+ bodyObj.optString("message", "BES update failed"),
5152
+ )
5153
+ val besOtaStatus = mapping.status
5154
+ val besOtaProgressVal = mapping.progress
5155
+ val besOtaErrorMessage = mapping.errorMessage
5156
+
5157
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
5158
+ besOtaHeartbeatGuard.clear()
5159
+ } else {
5160
+ // Refresh before progress de-duplication: raw progress proves the BES
5161
+ // transfer is active even when the rounded UI value is unchanged.
5162
+ besOtaHeartbeatGuard.refresh(SystemClock.elapsedRealtime())
5163
+ }
5164
+
5165
+ // Only send if nonterminal display progress changed to a new 5% increment.
5166
+ if ("PROGRESS" == besOtaStatus && besOtaProgressVal == lastBesOtaProgress) {
5167
+ return
4478
5168
  }
4479
- lastBesOtaProgress = progress
4480
5169
 
4481
5170
  Bridge.log(
4482
5171
  "LIVE: 📱 BES OTA progress via sr_adota - type: " +
@@ -4485,58 +5174,21 @@ class MentraLive : SGCManager() {
4485
5174
  rawProgress +
4486
5175
  "%, rounded: " +
4487
5176
  progress +
5177
+ "%, display: " +
5178
+ besOtaProgressVal +
4488
5179
  "%"
4489
5180
  )
4490
5181
 
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
5182
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
4505
5183
  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
5184
  } else {
4515
- // Unknown type, treat as progress
4516
- besOtaStatus = "PROGRESS"
4517
- besOtaProgressVal = progress
5185
+ lastBesOtaProgress = besOtaProgressVal
4518
5186
  }
4519
5187
 
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"
5188
+ val syntheticStatus = when (besOtaStatus) {
5189
+ "FINISHED" -> "step_complete"
5190
+ "FAILED" -> "failed"
5191
+ else -> "in_progress"
4540
5192
  }
4541
5193
  val sid = if (cachedOtaSessionId != null) cachedOtaSessionId!! else ""
4542
5194
  val totalSteps = if (cachedOtaTotalSteps > 0) cachedOtaTotalSteps else 1
@@ -5001,6 +5653,10 @@ class MentraLive : SGCManager() {
5001
5653
  Bridge.log("LIVE: Skipping heartbeat - glasses not ready or not connected")
5002
5654
  return
5003
5655
  }
5656
+ if (besOtaHeartbeatGuard.shouldSuppress(SystemClock.elapsedRealtime())) {
5657
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5658
+ return
5659
+ }
5004
5660
 
5005
5661
  try {
5006
5662
  // Send ping message (no ACK needed for heartbeats)
@@ -5092,6 +5748,8 @@ class MentraLive : SGCManager() {
5092
5748
 
5093
5749
  /** Start the micbeat mechanism - periodically enable custom audio TX */
5094
5750
  private fun startMicBeat() {
5751
+ // A resumed custom-audio stream starts a new sequence domain.
5752
+ DeviceManager.getInstance().resetMicSequenceBaseline()
5095
5753
  micOnCount++
5096
5754
  Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
5097
5755
  micBeatCount = 0
@@ -5233,8 +5891,16 @@ class MentraLive : SGCManager() {
5233
5891
  override fun findCompatibleDevices() {
5234
5892
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5235
5893
 
5236
- // Clear reconnection mode when user manually scans
5894
+ // A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
5895
+ // and replace any active fast reconnect scan with discovery mode.
5896
+ reconnectGeneration++
5897
+ if (isScanning) {
5898
+ stopScan()
5899
+ }
5900
+ manualDiscoveryActive = true
5237
5901
  isReconnecting = false
5902
+ // Discovery-only: do not treat leftover savedDeviceName as a connect target.
5903
+ explicitConnectByName = false
5238
5904
 
5239
5905
  if (bluetoothAdapter == null) {
5240
5906
  Log.e(TAG, "Bluetooth not available")
@@ -5246,50 +5912,123 @@ class MentraLive : SGCManager() {
5246
5912
  return
5247
5913
  }
5248
5914
 
5915
+ // Stale Classic ACL keeps Mentra Live from advertising — drop A2DP/HFP (keep bond)
5916
+ // so a pairing/discovery scan can see the unit again. Keep the active session's
5917
+ // audio; tear down other bonded Lives so a second unit can advertise.
5918
+ releaseStaleClassicForDiscovery()
5919
+
5249
5920
  // Start scanning for BLE devices
5250
5921
  startScan()
5251
5922
  }
5252
5923
 
5924
+ private fun releaseStaleClassicForDiscovery() {
5925
+ val adapter = bluetoothAdapter ?: return
5926
+ val bonded =
5927
+ try {
5928
+ adapter.bondedDevices?.toList().orEmpty()
5929
+ } catch (e: Exception) {
5930
+ Bridge.log("LIVE: Classic: discovery release — cannot list bonded: " + e.message)
5931
+ return
5932
+ }
5933
+ val mentraBonded =
5934
+ bonded.filter { isMentraLiveBluetoothName(safeDeviceName(it)) }
5935
+ if (mentraBonded.isEmpty()) {
5936
+ return
5937
+ }
5938
+ val currentAddress = connectedDevice?.address
5939
+ for (device in mentraBonded) {
5940
+ if (currentAddress != null && device.address.equals(currentAddress, ignoreCase = true)) {
5941
+ continue
5942
+ }
5943
+ Bridge.log(
5944
+ "LIVE: Classic: releasing stale A2DP/HFP before discovery for " +
5945
+ safeDeviceName(device) +
5946
+ " (" +
5947
+ device.address +
5948
+ ")"
5949
+ )
5950
+ disconnectClassicProfiles(device)
5951
+ }
5952
+ }
5953
+
5253
5954
  override fun connectById(id: String) {
5955
+ if (pairingYieldActive) {
5956
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
5957
+ return
5958
+ }
5959
+ reconnectGeneration++
5960
+ manualDiscoveryActive = false
5961
+ if (isScanning) {
5962
+ stopScan()
5963
+ }
5964
+ isReconnecting = false
5254
5965
  Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
5255
5966
  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());
5967
+ // Without this, connectToSmartGlasses() falls back to a name scan with
5968
+ // isReconnecting=false and never GATT-connects (loading screen hangs).
5969
+ explicitConnectByName = true
5263
5970
  connectToSmartGlasses()
5264
5971
  }
5265
5972
 
5973
+ /**
5974
+ * True while Android is showing (or about to show) the system pairing dialog for
5975
+ * CTKD Classic bonding. Calling [removeBond] / tearing down the SGC here aborts that
5976
+ * dialog and forces a full re-pair.
5977
+ */
5978
+ fun isCtkdBondingInProgress(): Boolean {
5979
+ val device = connectedDevice ?: return false
5980
+ return device.bondState == BluetoothDevice.BOND_BONDING
5981
+ }
5982
+
5266
5983
  override fun forget() {
5267
5984
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
5268
5985
 
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");
5986
+ // Clear saved device name so a leftover name can't bypass the pairing-mode
5987
+ // discovery filter for this unit on a subsequent rescan (parity with iOS forget()).
5988
+ savedDeviceName = ""
5273
5989
 
5274
5990
  // Reset reconnection state
5275
5991
  reconnectAttempts = 0
5276
5992
  isReconnecting = false
5993
+ manualDiscoveryActive = false
5994
+ explicitConnectByName = false
5277
5995
 
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
-
5996
+ val ctx = context ?: Bridge.getContext()
5997
+ val bondedAddress = connectedDevice?.address
5285
5998
  if (isScanning) {
5286
5999
  stopScan()
5287
6000
  emitStopScanEvent()
5288
6001
  }
5289
- disconnect()
6002
+ // Tell glasses to drop Mentra owner before tearing the link. removeBond
6003
+ // alone leaves glasses NV owned, so the next pair hits OWNER_ONLY.
6004
+ try {
6005
+ val unpair = JSONObject()
6006
+ unpair.put("type", "unpair")
6007
+ sendJsonWithoutAck(unpair, true)
6008
+ } catch (e: JSONException) {
6009
+ Log.e(TAG, "Error creating unpair command", e)
6010
+ }
6011
+ // Give the unpair write a beat to leave GATT before destroy() cancels the queue.
6012
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
6013
+ unpairFlushPending = true
6014
+ val flush =
6015
+ Runnable {
6016
+ unpairFlushPending = false
6017
+ unpairFlushRunnable = null
6018
+ // Drop GATT first. removeBond while the BLE link is still encrypted often
6019
+ // returns true on Samsung but the OS Bluetooth UI keeps the dual-mode pair.
6020
+ disconnect()
6021
+ unbondBondedMentraLiveDevices(ctx, bondedAddress)
6022
+ }
6023
+ unpairFlushRunnable = flush
6024
+ handler.postDelayed(flush, UNPAIR_FLUSH_MS)
5290
6025
  }
5291
6026
 
5292
6027
  override fun disconnect() {
6028
+ if (unpairFlushPending) {
6029
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6030
+ return
6031
+ }
5293
6032
  Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
5294
6033
  destroy()
5295
6034
  }
@@ -5327,6 +6066,10 @@ class MentraLive : SGCManager() {
5327
6066
  }
5328
6067
 
5329
6068
  fun connectToSmartGlasses() {
6069
+ if (pairingYieldActive) {
6070
+ Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6071
+ return
6072
+ }
5330
6073
  Bridge.log("LIVE: Connecting to Mentra Live glasses")
5331
6074
  updateConnectionState(ConnTypes.CONNECTING)
5332
6075
 
@@ -5355,7 +6098,13 @@ class MentraLive : SGCManager() {
5355
6098
  // var context = Bridge.getContext();
5356
6099
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5357
6100
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
5358
- val lastDeviceAddress = DeviceStore.get("bluetooth", "device_address") as String?
6101
+ val pendingAddress =
6102
+ (DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
6103
+ it.isNotEmpty()
6104
+ }
6105
+ val lastDeviceAddress =
6106
+ pendingAddress
6107
+ ?: (DeviceStore.get("bluetooth", "device_address") as String?)
5359
6108
 
5360
6109
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
5361
6110
  // Connect to last known device if available
@@ -5366,6 +6115,7 @@ class MentraLive : SGCManager() {
5366
6115
  Bridge.log(
5367
6116
  "LIVE: Found saved device, connecting directly: " + lastDeviceAddress
5368
6117
  )
6118
+ prepareClassicBeforeBleConnect(device)
5369
6119
  connectToDevice(device)
5370
6120
  } else {
5371
6121
  Bridge.log(
@@ -5381,12 +6131,62 @@ class MentraLive : SGCManager() {
5381
6131
  startScan() // Fallback to scanning
5382
6132
  }
5383
6133
  } else {
5384
- // If no last known device, start scanning for devices
5385
- Bridge.log("LIVE: No last known device, starting scan")
5386
- startScan()
6134
+ // Pairing may only have a name (or address wasn't stashed). Prefer a bonded
6135
+ // device match over scan Classic-connected glasses often stop advertising.
6136
+ val bonded = findBondedDeviceByName(savedDeviceName)
6137
+ if (bonded != null) {
6138
+ Bridge.log(
6139
+ "LIVE: No saved address — using bonded device " +
6140
+ safeDeviceName(bonded) +
6141
+ " (" +
6142
+ bonded.address +
6143
+ ")"
6144
+ )
6145
+ prepareClassicBeforeBleConnect(bonded)
6146
+ connectToDevice(bonded)
6147
+ } else {
6148
+ Bridge.log("LIVE: No last known device, starting scan")
6149
+ startScan()
6150
+ }
6151
+ }
6152
+ }
6153
+
6154
+ private fun findBondedDeviceByName(name: String): BluetoothDevice? {
6155
+ if (name.isEmpty() || bluetoothAdapter == null) {
6156
+ return null
6157
+ }
6158
+ return try {
6159
+ bluetoothAdapter!!.bondedDevices?.firstOrNull { safeDeviceName(it) == name }
6160
+ } catch (e: SecurityException) {
6161
+ Bridge.log("LIVE: Unable to read bonded devices: " + e.message)
6162
+ null
6163
+ } catch (e: Exception) {
6164
+ Bridge.log("LIVE: Error reading bonded devices: " + e.message)
6165
+ null
5387
6166
  }
5388
6167
  }
5389
6168
 
6169
+ /**
6170
+ * Fresh pairing / discovery may need Classic down so glasses leave a stuck ACL.
6171
+ * Owner reconnect must NOT tear Classic first: BES BLE-PAIR-GUARD historically
6172
+ * admitted Mentra App RPAs only while Classic owner ACL was up — tearing it
6173
+ * caused GATT status=19 reject loops after Mentra App disconnect.
6174
+ */
6175
+ private fun prepareClassicBeforeBleConnect(device: BluetoothDevice) {
6176
+ if (device.bondState != BluetoothDevice.BOND_BONDED) {
6177
+ return
6178
+ }
6179
+ // Owner reconnect and connectById both go through connectToSmartGlasses(),
6180
+ // which clears isReconnecting first. Leave Classic up: tearing A2DP/HFP
6181
+ // here historically caused GATT status=19 reject loops. Discovery teardown
6182
+ // lives in releaseStaleClassicForDiscovery() for pairing scans only.
6183
+ Bridge.log(
6184
+ "LIVE: Classic: bonded target — leave A2DP/HFP up for BLE admit (" +
6185
+ safeDeviceName(device) +
6186
+ ")"
6187
+ )
6188
+ }
6189
+
5390
6190
  override fun setMicEnabled(enabled: Boolean) {
5391
6191
  Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
5392
6192
 
@@ -5830,41 +6630,67 @@ class MentraLive : SGCManager() {
5830
6630
  Bridge.log(
5831
6631
  "LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
5832
6632
  )
6633
+ markPairingTiming(
6634
+ "ctkd_bonded",
6635
+ "glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
6636
+ )
5833
6637
  if (isKilled) {
5834
6638
  Bridge.log(
5835
6639
  "LIVE: CTKD: Ignoring bond complete — SGC destroyed"
5836
6640
  )
5837
6641
  return@run
5838
6642
  }
5839
- isBtClassicConnected = true
5840
- audioConnected = true
5841
6643
  bondingRetryCount =
5842
6644
  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
6645
+ // Bond != audio profile. Explicitly open A2DP
6646
+ // after createBond() completes (already-bonded
6647
+ // path does this at GATT connect).
6648
+ // markAudioConnected / sendAudioConnected fire
6649
+ // when the A2DP path completes.
6650
+ connectA2dpProfile(device)
6651
+
6652
+ // Safety net: if glasses_ready somehow missed
6653
+ // setting fullyBooted, catch up here.
5848
6654
  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)
6655
+ val alreadyBooted =
6656
+ DeviceStore.store.get(
6657
+ "glasses",
6658
+ "fullyBooted"
6659
+ ) as? Boolean
6660
+ ?: false
6661
+ if (!alreadyBooted) {
6662
+ Bridge.log(
6663
+ "LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
6664
+ )
6665
+ endPairingTiming(
6666
+ "fully_connected",
6667
+ "via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
6668
+ )
6669
+ DeviceStore.apply(
6670
+ "glasses",
6671
+ "fullyBooted",
6672
+ true
6673
+ )
6674
+ updateConnectionState(ConnTypes.CONNECTED)
6675
+ } else {
6676
+ Bridge.log(
6677
+ "LIVE: Audio: CTKD bonded after pairing UI already advanced"
6678
+ )
6679
+ markPairingTiming(
6680
+ "ctkd_bonded_after_fully_connected",
6681
+ "queueSize=${sendQueue.size}"
6682
+ )
6683
+ }
5858
6684
  }
5859
-
5860
- // Send audio connected event for platform parity
5861
- // with iOS
5862
- Bridge.sendAudioConnected(device.name)
5863
6685
  }
5864
6686
  BluetoothDevice.BOND_NONE -> {
5865
6687
  Bridge.log(
5866
6688
  "LIVE: CTKD: ❌ Bonding failed or removed for device"
5867
6689
  )
6690
+ markPairingTiming(
6691
+ "ctkd_bond_none",
6692
+ "prev=$previousBondState queueSize=${sendQueue.size}"
6693
+ )
5868
6694
  isBtClassicConnected = false
5869
6695
  audioConnected = false
5870
6696
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
@@ -5925,10 +6751,12 @@ class MentraLive : SGCManager() {
5925
6751
  Bridge.sendAudioDisconnected()
5926
6752
  }
5927
6753
  }
5928
- BluetoothDevice.BOND_BONDING ->
6754
+ BluetoothDevice.BOND_BONDING -> {
5929
6755
  Bridge.log(
5930
6756
  "LIVE: CTKD: 🔄 Bonding in progress with device"
5931
6757
  )
6758
+ markPairingTiming("ctkd_bonding")
6759
+ }
5932
6760
  else ->
5933
6761
  Bridge.log(
5934
6762
  "LIVE: CTKD: Unknown bond state: " + bondState
@@ -5963,6 +6791,39 @@ class MentraLive : SGCManager() {
5963
6791
  }
5964
6792
  }
5965
6793
 
6794
+ /**
6795
+ * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
6796
+ * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
6797
+ * (peer terminate) on reconnect when A2DP is already up.
6798
+ */
6799
+ private fun maybeInitiateCtkdAudioAfterBleReady() {
6800
+ if (isKilled) {
6801
+ return
6802
+ }
6803
+ val device = connectedDevice
6804
+ if (device == null) {
6805
+ Bridge.log("LIVE: CTKD: Skipping initiate — no connected device")
6806
+ return
6807
+ }
6808
+ if (ctkdInitiatedThisGattSession) {
6809
+ return
6810
+ }
6811
+ ctkdInitiatedThisGattSession = true
6812
+ Bridge.log(
6813
+ "LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
6814
+ device.bondState +
6815
+ ")"
6816
+ )
6817
+ markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
6818
+ if (device.bondState == BluetoothDevice.BOND_BONDED) {
6819
+ Bridge.log("LIVE: CTKD: Device is already bonded - connecting A2DP audio profile")
6820
+ markPairingTiming("ctkd_already_bonded")
6821
+ connectA2dpProfile(device)
6822
+ } else {
6823
+ createBond(device)
6824
+ }
6825
+ }
6826
+
5966
6827
  /**
5967
6828
  * Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
5968
6829
  * BLE and BT Classic connections automatically
@@ -6069,6 +6930,7 @@ class MentraLive : SGCManager() {
6069
6930
 
6070
6931
  if (state == BluetoothProfile.STATE_CONNECTED) {
6071
6932
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
6933
+ a2dpConnectAttempts = 0
6072
6934
  markAudioConnected(device.name)
6073
6935
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
6074
6936
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
@@ -6077,35 +6939,48 @@ class MentraLive : SGCManager() {
6077
6939
  BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
6078
6940
  val result = connectMethod.invoke(a2dpProfile, device) as Boolean
6079
6941
  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)
6942
+ if (result) {
6943
+ scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
6944
+ } else {
6945
+ scheduleA2dpConnectRetry(device, "connect() returned false")
6946
+ }
6086
6947
  } else if (state == BluetoothProfile.STATE_CONNECTING) {
6087
- Bridge.log("LIVE: A2DP: Already connecting, marking audio as connected")
6088
- markAudioConnected(device.name)
6948
+ Bridge.log("LIVE: A2DP: Already connecting waiting for STATE_CONNECTED")
6949
+ scheduleA2dpConnectRetry(device, "still connecting")
6089
6950
  } 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
- )
6951
+ scheduleA2dpConnectRetry(device, "device disconnecting")
6103
6952
  }
6104
6953
  } catch (e: Exception) {
6105
6954
  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)
6955
+ scheduleA2dpConnectRetry(device, "exception: ${e.message}")
6956
+ }
6957
+ }
6958
+
6959
+ private fun scheduleA2dpConnectRetry(device: BluetoothDevice, reason: String) {
6960
+ a2dpConnectAttempts++
6961
+ if (a2dpConnectAttempts > A2DP_CONNECT_MAX_ATTEMPTS) {
6962
+ Bridge.log(
6963
+ "LIVE: A2DP: Giving up after $A2DP_CONNECT_MAX_ATTEMPTS attempts ($reason). " +
6964
+ "Classic bond may exist, but A2DP/HFP is not up. BLE/LC3 still works."
6965
+ )
6966
+ return
6108
6967
  }
6968
+ Bridge.log(
6969
+ "LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
6970
+ "${A2DP_CONNECT_RETRY_MS}ms ($reason)"
6971
+ )
6972
+ handler.postDelayed(
6973
+ Runnable {
6974
+ if (isKilled || pairingYieldActive || connectedDevice == null) {
6975
+ return@Runnable
6976
+ }
6977
+ val target = connectedDevice ?: return@Runnable
6978
+ if (a2dpProfile != null) {
6979
+ connectA2dpWithProxy(target)
6980
+ }
6981
+ },
6982
+ A2DP_CONNECT_RETRY_MS
6983
+ )
6109
6984
  }
6110
6985
 
6111
6986
  /** Helper to mark audio as connected and notify */
@@ -6123,8 +6998,14 @@ class MentraLive : SGCManager() {
6123
6998
  Bridge.log(
6124
6999
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
6125
7000
  )
7001
+ endPairingTiming(
7002
+ "fully_connected",
7003
+ "via=a2dp+glasses_ready queueSize=${sendQueue.size}"
7004
+ )
6126
7005
  DeviceStore.apply("glasses", "fullyBooted", true)
6127
7006
  updateConnectionState(ConnTypes.CONNECTED)
7007
+ } else {
7008
+ markPairingTiming("a2dp_connected_waiting_glasses_ready")
6128
7009
  }
6129
7010
  }
6130
7011
 
@@ -6148,6 +7029,7 @@ class MentraLive : SGCManager() {
6148
7029
  }
6149
7030
 
6150
7031
  Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
7032
+ a2dpConnectAttempts = 0
6151
7033
 
6152
7034
  // If we already have the proxy, try to connect directly
6153
7035
  if (a2dpProfile != null) {
@@ -6168,16 +7050,133 @@ class MentraLive : SGCManager() {
6168
7050
  Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
6169
7051
  } else {
6170
7052
  Bridge.log(
6171
- "LIVE: A2DP: Failed to get profile proxy, marking audio connected anyway"
7053
+ "LIVE: A2DP: Failed to get profile proxy Classic audio not connected"
6172
7054
  )
6173
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6174
- markAudioConnected(device.name)
6175
7055
  }
6176
7056
  } else {
6177
7057
  Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
6178
7058
  }
6179
7059
  }
6180
7060
 
7061
+ /**
7062
+ * Drop Classic audio profiles (A2DP + HFP). App disconnect previously only closed BLE GATT /
7063
+ * the A2DP proxy, so Android Settings still showed Mentra Live as connected and the next BLE
7064
+ * reconnect fought a live Classic link (GATT 19). Bond is intentionally kept for reconnect —
7065
+ * only [forget] calls removeBond.
7066
+ */
7067
+ private fun disconnectClassicProfiles(device: BluetoothDevice?) {
7068
+ if (device == null || bluetoothAdapter == null || context == null) {
7069
+ Bridge.log("LIVE: Classic: skip profile teardown — missing device/adapter/context")
7070
+ return
7071
+ }
7072
+ Bridge.log(
7073
+ "LIVE: Classic: tearing down A2DP/HFP for " +
7074
+ safeDeviceName(device) +
7075
+ " (" +
7076
+ device.address +
7077
+ ")"
7078
+ )
7079
+ val isActiveDevice =
7080
+ connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7081
+ if (isActiveDevice) {
7082
+ isBtClassicConnected = false
7083
+ audioConnected = false
7084
+ }
7085
+
7086
+ if (a2dpProfile != null) {
7087
+ invokeHiddenProfileDisconnect(
7088
+ a2dpProfile!!,
7089
+ device,
7090
+ BluetoothA2dp::class.java,
7091
+ "A2DP"
7092
+ )
7093
+ } else {
7094
+ requestProfileDisconnect(BluetoothProfile.A2DP, device, BluetoothA2dp::class.java, "A2DP")
7095
+ }
7096
+ // HFP/SCO often stays up after A2DP-only teardown (shows as "Bluetooth Headset" in routes).
7097
+ requestProfileDisconnect(
7098
+ BluetoothProfile.HEADSET,
7099
+ device,
7100
+ BluetoothHeadset::class.java,
7101
+ "HFP"
7102
+ )
7103
+ }
7104
+
7105
+ private fun invokeHiddenProfileDisconnect(
7106
+ proxy: BluetoothProfile,
7107
+ device: BluetoothDevice,
7108
+ proxyClass: Class<*>,
7109
+ label: String
7110
+ ) {
7111
+ try {
7112
+ val state = proxy.getConnectionState(device)
7113
+ Bridge.log("LIVE: Classic: " + label + " state=" + state)
7114
+ if (state == BluetoothProfile.STATE_CONNECTED ||
7115
+ state == BluetoothProfile.STATE_CONNECTING
7116
+ ) {
7117
+ val disconnectMethod =
7118
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
7119
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
7120
+ Bridge.log("LIVE: Classic: " + label + " disconnect result=" + result)
7121
+ } else {
7122
+ Bridge.log("LIVE: Classic: " + label + " already idle — nothing to disconnect")
7123
+ }
7124
+ } catch (e: Exception) {
7125
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
7126
+ }
7127
+ }
7128
+
7129
+ private fun requestProfileDisconnect(
7130
+ profile: Int,
7131
+ device: BluetoothDevice,
7132
+ proxyClass: Class<*>,
7133
+ label: String
7134
+ ) {
7135
+ val adapter = bluetoothAdapter ?: return
7136
+ val ctx = context ?: return
7137
+ try {
7138
+ val ok =
7139
+ adapter.getProfileProxy(
7140
+ ctx,
7141
+ object : BluetoothProfile.ServiceListener {
7142
+ override fun onServiceConnected(
7143
+ profileType: Int,
7144
+ proxy: BluetoothProfile
7145
+ ) {
7146
+ if (profileType != profile) {
7147
+ return
7148
+ }
7149
+ try {
7150
+ invokeHiddenProfileDisconnect(
7151
+ proxy,
7152
+ device,
7153
+ proxyClass,
7154
+ label
7155
+ )
7156
+ } finally {
7157
+ try {
7158
+ adapter.closeProfileProxy(profile, proxy)
7159
+ } catch (e: Exception) {
7160
+ Bridge.log(
7161
+ "LIVE: Classic: error closing " +
7162
+ label +
7163
+ " proxy: " +
7164
+ e.message
7165
+ )
7166
+ }
7167
+ }
7168
+ }
7169
+
7170
+ override fun onServiceDisconnected(profileType: Int) {}
7171
+ },
7172
+ profile
7173
+ )
7174
+ Bridge.log("LIVE: Classic: requested " + label + " proxy for disconnect, ok=" + ok)
7175
+ } catch (e: Exception) {
7176
+ Bridge.log("LIVE: Classic: failed to request " + label + " proxy: " + e.message)
7177
+ }
7178
+ }
7179
+
6181
7180
  /** Close the A2DP profile proxy */
6182
7181
  private fun closeA2dpProxy() {
6183
7182
  if (a2dpProfile != null && bluetoothAdapter != null) {
@@ -6193,6 +7192,14 @@ class MentraLive : SGCManager() {
6193
7192
 
6194
7193
  // Mark as killed to prevent reconnection attempts
6195
7194
  isKilled = true
7195
+ explicitConnectByName = false
7196
+ pairingYieldActive = false
7197
+ pairingYieldAwaitingReclaim = false
7198
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
7199
+ pairingYieldEndRunnable = null
7200
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
7201
+ unpairFlushRunnable = null
7202
+ unpairFlushPending = false
6196
7203
 
6197
7204
  // Stop scanning if in progress
6198
7205
  if (isScanning) {
@@ -6203,7 +7210,10 @@ class MentraLive : SGCManager() {
6203
7210
  // CTKD Implementation: Unregister bonding receiver
6204
7211
  unregisterBondingReceiver()
6205
7212
 
6206
- // Close A2DP profile proxy
7213
+ // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7214
+ // phone's system Bluetooth UI showing Mentra Live still connected.
7215
+ val classicDevice = connectedDevice
7216
+ disconnectClassicProfiles(classicDevice)
6207
7217
  closeA2dpProxy()
6208
7218
 
6209
7219
  // Stop readiness check loop
@@ -6274,6 +7284,9 @@ class MentraLive : SGCManager() {
6274
7284
  reconnectAttempts = 0
6275
7285
  isReconnecting = false
6276
7286
  glassesReady = false
7287
+ glassesReadyReceived = false
7288
+ audioConnected = false
7289
+ ctkdInitiatedThisGattSession = false
6277
7290
  DeviceStore.apply("glasses", "fullyBooted", false)
6278
7291
  updateConnectionState(ConnTypes.DISCONNECTED)
6279
7292
 
@@ -7293,6 +8306,7 @@ class MentraLive : SGCManager() {
7293
8306
  /** Sends phone_ready to (re)start the glasses-driven readiness flow. */
7294
8307
  private fun sendPhoneReady(reason: String) {
7295
8308
  Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
8309
+ markPairingTiming("phone_ready_sent", "reason=$reason queueSize=${sendQueue.size}")
7296
8310
  val readyMsg = JSONObject()
7297
8311
  readyMsg.put("type", "phone_ready")
7298
8312
  readyMsg.put("timestamp", System.currentTimeMillis())
@@ -7416,6 +8430,10 @@ class MentraLive : SGCManager() {
7416
8430
  payload
7417
8431
  )
7418
8432
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
8433
+ markPairingTiming(
8434
+ "wire_handshake_sent",
8435
+ "attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
8436
+ )
7419
8437
  wireHandshakeQueued = true
7420
8438
  wireHandshakeSentGeneration = wireSessionGeneration
7421
8439
  queueData(packed, null)
@@ -7442,6 +8460,10 @@ class MentraLive : SGCManager() {
7442
8460
  WIRE_HANDSHAKE_MAX_ATTEMPTS +
7443
8461
  ") - re-arming"
7444
8462
  )
8463
+ markPairingTiming(
8464
+ "wire_handshake_unanswered",
8465
+ "attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
8466
+ )
7445
8467
  wireHandshakeQueued = false
7446
8468
  if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7447
8469
  maybeSendWireHandshake()
@@ -7470,6 +8492,7 @@ class MentraLive : SGCManager() {
7470
8492
  Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7471
8493
  return
7472
8494
  }
8495
+ markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
7473
8496
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7474
8497
  }
7475
8498
 
@@ -7570,10 +8593,22 @@ class MentraLive : SGCManager() {
7570
8593
  * @param data The string data to be sent to the glasses
7571
8594
  */
7572
8595
  fun sendDataToGlasses(data: String?, wakeup: Boolean) {
8596
+ sendDataToGlasses(data, wakeup, bleSessionGeneration.get())
8597
+ }
8598
+
8599
+ private fun sendDataToGlasses(
8600
+ data: String?,
8601
+ wakeup: Boolean,
8602
+ sessionGeneration: Long
8603
+ ) {
7573
8604
  if (data == null || data.isEmpty()) {
7574
8605
  Log.e(TAG, "Cannot send empty data to glasses")
7575
8606
  return
7576
8607
  }
8608
+ if (sessionGeneration != bleSessionGeneration.get()) {
8609
+ Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
8610
+ return
8611
+ }
7577
8612
 
7578
8613
  val wireData =
7579
8614
  try {
@@ -7600,7 +8635,13 @@ class MentraLive : SGCManager() {
7600
8635
  }
7601
8636
 
7602
8637
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
7603
- sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
8638
+ sendDataToGlassesBinary(
8639
+ wireData,
8640
+ wakeup,
8641
+ commandTraceInfo,
8642
+ isPhotoRequest,
8643
+ sessionGeneration
8644
+ )
7604
8645
  return
7605
8646
  }
7606
8647
 
@@ -7679,7 +8720,7 @@ class MentraLive : SGCManager() {
7679
8720
  )
7680
8721
 
7681
8722
  // Queue the chunk for sending
7682
- queueData(packedData, trace)
8723
+ queueData(packedData, trace, sessionGeneration)
7683
8724
 
7684
8725
  // Add small delay between chunks to avoid overwhelming the connection
7685
8726
  if (i < chunks.size - 1) {
@@ -7720,7 +8761,7 @@ class MentraLive : SGCManager() {
7720
8761
  )
7721
8762
 
7722
8763
  // Queue the data for sending
7723
- queueData(packedData, trace)
8764
+ queueData(packedData, trace, sessionGeneration)
7724
8765
  if (isPhotoRequest) {
7725
8766
  Bridge.log(
7726
8767
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7738,7 +8779,8 @@ class MentraLive : SGCManager() {
7738
8779
  data: String,
7739
8780
  wakeup: Boolean,
7740
8781
  commandTraceInfo: OutgoingBleCommandTraceInfo,
7741
- isPhotoRequest: Boolean
8782
+ isPhotoRequest: Boolean,
8783
+ sessionGeneration: Long
7742
8784
  ) {
7743
8785
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7744
8786
  var messageId = 0
@@ -7790,7 +8832,7 @@ class MentraLive : SGCManager() {
7790
8832
  "wireProtocol" to "v2"
7791
8833
  )
7792
8834
  )
7793
- queueData(packedData, trace)
8835
+ queueData(packedData, trace, sessionGeneration)
7794
8836
 
7795
8837
  if (i < fragments.size - 1) {
7796
8838
  try {
@@ -8124,6 +9166,19 @@ class MentraLive : SGCManager() {
8124
9166
  }
8125
9167
  }
8126
9168
 
9169
+ override fun sendWifiAdbState(enabled: Boolean) {
9170
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state to glasses - enabled: " + enabled)
9171
+ try {
9172
+ val command = JSONObject()
9173
+ command.put("type", "set_wifi_adb_state")
9174
+ command.put("enabled", enabled)
9175
+ sendJson(command, true)
9176
+ Bridge.log("LIVE: 🔧 ✅ Wi-Fi ADB state command sent successfully")
9177
+ } catch (e: JSONException) {
9178
+ Log.e(TAG, "🔧 💥 Error creating Wi-Fi ADB state JSON", e)
9179
+ }
9180
+ }
9181
+
8127
9182
  override fun sendSetSystemTime(timestampMs: Long) {
8128
9183
  Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
8129
9184
  try {
@@ -9311,6 +10366,21 @@ class MentraLive : SGCManager() {
9311
10366
  stopVideoRecording(requestId, null, null)
9312
10367
  }
9313
10368
 
10369
+ override fun queryVideoRecordingStatus(requestId: String) {
10370
+ if (!isConnected) {
10371
+ Log.w(TAG, "Cannot query video recording status - not connected")
10372
+ return
10373
+ }
10374
+ try {
10375
+ val json = JSONObject()
10376
+ json.put("type", "get_video_recording_status")
10377
+ json.put("requestId", requestId)
10378
+ sendJson(json, true)
10379
+ } catch (e: JSONException) {
10380
+ Log.e(TAG, "Failed to create video recording status query", e)
10381
+ }
10382
+ }
10383
+
9314
10384
  override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
9315
10385
  Bridge.log(
9316
10386
  "LIVE: Stopping video recording: requestId=" +
@@ -9361,20 +10431,6 @@ class MentraLive : SGCManager() {
9361
10431
  val sequenceNumber = data[1]
9362
10432
  val receiveTime = System.currentTimeMillis()
9363
10433
 
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
10434
  val lc3Data = Arrays.copyOfRange(data, 2, data.size)
9379
10435
 
9380
10436
  // Enhanced LC3 packet logging and saving
@@ -9386,7 +10442,11 @@ class MentraLive : SGCManager() {
9386
10442
 
9387
10443
  // Forward raw LC3 to DeviceManager (matches iOS behavior)
9388
10444
  // MentraLive uses 40-byte LC3 frames
9389
- DeviceManager.getInstance().handleGlassesMicData(lc3Data, LC3_FRAME_SIZE)
10445
+ DeviceManager.getInstance().handleGlassesMicData(
10446
+ lc3Data,
10447
+ LC3_FRAME_SIZE,
10448
+ sequenceNumber.toInt() and 0xff,
10449
+ )
9390
10450
 
9391
10451
  // Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
9392
10452
  // } else {