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

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 (144) hide show
  1. package/README.md +67 -25
  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/GeneratedChangelogCatalog.kt +7 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  13. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  14. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  22. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  29. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  30. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  31. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  32. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  42. package/build/BluetoothSdk.types.d.ts +84 -2
  43. package/build/BluetoothSdk.types.d.ts.map +1 -1
  44. package/build/BluetoothSdk.types.js.map +1 -1
  45. package/build/_internal.d.ts +2 -0
  46. package/build/_internal.d.ts.map +1 -1
  47. package/build/_internal.js +2 -0
  48. package/build/_internal.js.map +1 -1
  49. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  50. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  51. package/build/_private/BluetoothSdkModule.js +1 -1
  52. package/build/_private/BluetoothSdkModule.js.map +1 -1
  53. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  54. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  55. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  56. package/build/_private/sdkOtaManifest.d.ts +3 -12
  57. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  58. package/build/_private/sdkOtaManifest.js +5 -17
  59. package/build/_private/sdkOtaManifest.js.map +1 -1
  60. package/build/changelogs.d.ts +7 -0
  61. package/build/changelogs.d.ts.map +1 -0
  62. package/build/changelogs.js +44 -0
  63. package/build/changelogs.js.map +1 -0
  64. package/build/generated/changelogCatalog.d.ts +6 -0
  65. package/build/generated/changelogCatalog.d.ts.map +1 -0
  66. package/build/generated/changelogCatalog.js +8 -0
  67. package/build/generated/changelogCatalog.js.map +1 -0
  68. package/build/generated/releaseMetadata.d.ts +12 -0
  69. package/build/generated/releaseMetadata.d.ts.map +1 -0
  70. package/build/generated/releaseMetadata.js +10 -0
  71. package/build/generated/releaseMetadata.js.map +1 -0
  72. package/build/index.d.ts +1 -1
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +24 -2
  75. package/build/index.js.map +1 -1
  76. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  77. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  78. package/build/ota-server/MentraOtaServer.types.js +2 -0
  79. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  80. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  81. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  82. package/build/ota-server/MentraOtaServerModule.js +3 -0
  83. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  84. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  85. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  86. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  87. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  88. package/build/ota-server/index.d.ts +3 -0
  89. package/build/ota-server/index.d.ts.map +1 -0
  90. package/build/ota-server/index.js +3 -0
  91. package/build/ota-server/index.js.map +1 -0
  92. package/build/ota-transport/index.d.ts +67 -0
  93. package/build/ota-transport/index.d.ts.map +1 -0
  94. package/build/ota-transport/index.js +58 -0
  95. package/build/ota-transport/index.js.map +1 -0
  96. package/expo-module.config.json +3 -2
  97. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  98. package/ios/BluetoothSdkModule.swift +16 -2
  99. package/ios/LocalIPv4.swift +150 -0
  100. package/ios/LocalOtaServer.swift +309 -0
  101. package/ios/MentraBluetoothSDK.podspec +5 -1
  102. package/ios/MentraOtaServerModule.swift +147 -0
  103. package/ios/MentraPhotoReceiverModule.swift +1 -81
  104. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  105. package/ios/Source/Bridge.swift +43 -4
  106. package/ios/Source/DeviceManager.swift +154 -19
  107. package/ios/Source/DeviceStore.swift +2 -0
  108. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  109. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  110. package/ios/Source/MentraBluetoothSDK.swift +91 -6
  111. package/ios/Source/OtaManifest.swift +5 -7
  112. package/ios/Source/ReleaseChangelog.swift +65 -0
  113. package/ios/Source/audio/AudioModels.swift +17 -0
  114. package/ios/Source/camera/CameraModels.swift +1 -0
  115. package/ios/Source/events/BluetoothEvents.swift +27 -0
  116. package/ios/Source/sgcs/MentraLive.swift +512 -78
  117. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  118. package/ios/Source/sgcs/SGCManager.swift +11 -0
  119. package/ios/Source/status/DeviceStatus.swift +23 -0
  120. package/ios/Source/types/DeviceModels.swift +23 -2
  121. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  122. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  123. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  124. package/package.json +10 -1
  125. package/plugin/build/withIos.d.ts +3 -1
  126. package/plugin/build/withIos.js +22 -10
  127. package/scripts/public-ota-api.test.mjs +37 -0
  128. package/scripts/verify-release-package.mjs +34 -0
  129. package/scripts/write-release-metadata.mjs +154 -0
  130. package/scripts/write-release-metadata.test.mjs +29 -0
  131. package/src/BluetoothSdk.types.ts +97 -3
  132. package/src/_internal.ts +2 -0
  133. package/src/_private/BluetoothSdkModule.ts +5 -5
  134. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  135. package/src/_private/sdkOtaManifest.ts +5 -17
  136. package/src/changelogs.ts +44 -0
  137. package/src/generated/changelogCatalog.ts +9 -0
  138. package/src/generated/releaseMetadata.ts +20 -0
  139. package/src/index.ts +43 -2
  140. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  141. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  142. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  143. package/src/ota-server/index.ts +2 -0
  144. package/src/ota-transport/index.ts +116 -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,38 @@ 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
+
85
+ internal fun isPendingMentraLivePairingTarget(
86
+ connectedName: String,
87
+ connectedAddress: String,
88
+ pendingName: String,
89
+ pendingAddress: String,
90
+ ): Boolean {
91
+ if (pendingAddress.isNotEmpty()) {
92
+ return connectedAddress.equals(pendingAddress, ignoreCase = true)
93
+ }
94
+ return pendingName.isNotEmpty() && connectedName == pendingName
95
+ }
96
+
97
+ internal fun shouldRecoverConnectedMentraLivePairingTarget(
98
+ isConnected: Boolean,
99
+ connectedName: String,
100
+ connectedAddress: String,
101
+ pendingName: String,
102
+ pendingAddress: String,
103
+ ): Boolean =
104
+ isConnected &&
105
+ isPendingMentraLivePairingTarget(
106
+ connectedName,
107
+ connectedAddress,
108
+ pendingName,
109
+ pendingAddress,
110
+ )
111
+
78
112
  /**
79
113
  * Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
80
114
  * glasses
@@ -82,7 +116,14 @@ import org.json.JSONObject
82
116
  * Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
83
117
  * display-related methods are stubbed out and will log a message but not actually display anything.
84
118
  */
119
+ private fun jsonNullableString(json: org.json.JSONObject, key: String): String? {
120
+ if (!json.has(key) || json.isNull(key)) return null
121
+ val value = json.optString(key, "")
122
+ return value.takeIf { it.isNotEmpty() }
123
+ }
124
+
85
125
  class MentraLive : SGCManager() {
126
+ private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
86
127
 
87
128
  companion object {
88
129
  private const val TAG = "Live"
@@ -97,6 +138,9 @@ class MentraLive : SGCManager() {
97
138
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
98
139
  private const val LOUDNESS_GATE_SWITCH_TYPE = 10
99
140
  private const val BES2700_MTU_LIMIT = 509
141
+ private const val A2DP_CONNECT_MAX_ATTEMPTS = 5
142
+ private const val A2DP_CONNECT_RETRY_MS = 800L
143
+ private const val UNPAIR_FLUSH_MS = 400L
100
144
 
101
145
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
102
146
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
@@ -140,6 +184,253 @@ class MentraLive : SGCManager() {
140
184
  10000 // 10 seconds for reconnection scans (faster than 60s default)
141
185
  private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
142
186
  10_000L // 10s before first scan after shutdown
187
+
188
+ private const val MENTRA_MANUFACTURER_ID = 0xB822
189
+
190
+ private fun manufacturerData(result: ScanResult): ByteArray? {
191
+ return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
192
+ }
193
+
194
+ private fun isPairingDiscoverable(result: ScanResult): Boolean {
195
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result))?.pairingMode ==
196
+ true
197
+ }
198
+
199
+ private fun advertisesPairingFlag(result: ScanResult): Boolean {
200
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result)) != null
201
+ }
202
+
203
+ private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
204
+ val advertisement =
205
+ MentraLivePairingAdvertisementParser.parse(manufacturerData(result))
206
+ ?: return Triple(false, null, false)
207
+ return Triple(advertisement.pairingMode, advertisement.pairingCode, true)
208
+ }
209
+
210
+ fun isMentraLiveBluetoothName(name: String?): Boolean {
211
+ if (name.isNullOrEmpty()) {
212
+ return false
213
+ }
214
+ return name == "Xy_A" ||
215
+ name.startsWith("XyBLE_") ||
216
+ name.startsWith("MENTRA_LIVE_BLE") ||
217
+ name.startsWith("MENTRA_LIVE_BT") ||
218
+ name.lowercase().startsWith("mentra_live")
219
+ }
220
+
221
+ private fun safeBondedDeviceName(device: BluetoothDevice): String {
222
+ return try {
223
+ device.name ?: ""
224
+ } catch (_: SecurityException) {
225
+ ""
226
+ } catch (_: Exception) {
227
+ ""
228
+ }
229
+ }
230
+
231
+ private fun removeBondStandalone(device: BluetoothDevice): Boolean {
232
+ return try {
233
+ val method = device.javaClass.getMethod("removeBond")
234
+ val result = method.invoke(device) as Boolean
235
+ Bridge.log(
236
+ "LIVE: CTKD: removeBond " +
237
+ safeBondedDeviceName(device) +
238
+ " (" +
239
+ device.address +
240
+ ") result=" +
241
+ result
242
+ )
243
+ result
244
+ } catch (e: Exception) {
245
+ Bridge.log("LIVE: CTKD: removeBond error: " + e.message)
246
+ false
247
+ }
248
+ }
249
+
250
+ private fun invokeHiddenProfileDisconnectStandalone(
251
+ proxy: BluetoothProfile,
252
+ device: BluetoothDevice,
253
+ proxyClass: Class<*>,
254
+ label: String
255
+ ) {
256
+ try {
257
+ val state = proxy.getConnectionState(device)
258
+ if (state == BluetoothProfile.STATE_CONNECTED ||
259
+ state == BluetoothProfile.STATE_CONNECTING
260
+ ) {
261
+ val disconnectMethod =
262
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
263
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
264
+ Bridge.log(
265
+ "LIVE: Classic: " +
266
+ label +
267
+ " disconnect " +
268
+ device.address +
269
+ " result=" +
270
+ result
271
+ )
272
+ }
273
+ } catch (e: Exception) {
274
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
275
+ }
276
+ }
277
+
278
+ private fun requestProfileDisconnectStandalone(
279
+ context: Context,
280
+ adapter: BluetoothAdapter,
281
+ profile: Int,
282
+ device: BluetoothDevice,
283
+ proxyClass: Class<*>,
284
+ label: String
285
+ ) {
286
+ try {
287
+ adapter.getProfileProxy(
288
+ context,
289
+ object : BluetoothProfile.ServiceListener {
290
+ override fun onServiceConnected(
291
+ profileType: Int,
292
+ proxy: BluetoothProfile
293
+ ) {
294
+ if (profileType != profile) {
295
+ return
296
+ }
297
+ try {
298
+ invokeHiddenProfileDisconnectStandalone(
299
+ proxy,
300
+ device,
301
+ proxyClass,
302
+ label
303
+ )
304
+ } finally {
305
+ try {
306
+ adapter.closeProfileProxy(profile, proxy)
307
+ } catch (_: Exception) {
308
+ }
309
+ }
310
+ }
311
+
312
+ override fun onServiceDisconnected(profileType: Int) {}
313
+ },
314
+ profile
315
+ )
316
+ } catch (e: Exception) {
317
+ Bridge.log("LIVE: Classic: request " + label + " proxy failed: " + e.message)
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Drop A2DP/HFP + removeBond for every bonded Mentra Live unit. Safe when no SGC
323
+ * instance exists — [DeviceManager.forget] often runs after disconnect() nulled sgc
324
+ * (abandonAttempt), which previously skipped MentraLive.forget() entirely and left
325
+ * Classic ACL up so glasses never re-advertised.
326
+ */
327
+ @JvmStatic
328
+ @JvmOverloads
329
+ fun unbondBondedMentraLiveDevices(context: Context?, extraAddress: String? = null) {
330
+ if (context == null) {
331
+ Bridge.log("LIVE: CTKD: unbondBondedMentraLiveDevices — no context")
332
+ return
333
+ }
334
+ val adapter =
335
+ try {
336
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
337
+ ?.adapter
338
+ } catch (e: Exception) {
339
+ Bridge.log("LIVE: CTKD: unbond — adapter error: " + e.message)
340
+ null
341
+ }
342
+ if (adapter == null) {
343
+ Bridge.log("LIVE: CTKD: unbond — BluetoothAdapter unavailable")
344
+ return
345
+ }
346
+ val bonded =
347
+ try {
348
+ adapter.bondedDevices?.toList().orEmpty()
349
+ } catch (e: Exception) {
350
+ Bridge.log("LIVE: CTKD: unbond — cannot list bonded: " + e.message)
351
+ emptyList()
352
+ }
353
+ val extra = extraAddress?.uppercase()
354
+ val targets =
355
+ bonded.filter {
356
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
357
+ (extra != null && it.address.equals(extra, ignoreCase = true))
358
+ }
359
+ if (targets.isEmpty()) {
360
+ Bridge.log("LIVE: CTKD: unbond — no bonded Mentra Live devices")
361
+ return
362
+ }
363
+ for (device in targets) {
364
+ Bridge.log(
365
+ "LIVE: CTKD: tearing down bonded Mentra Live " +
366
+ safeBondedDeviceName(device) +
367
+ " (" +
368
+ device.address +
369
+ ")"
370
+ )
371
+ requestProfileDisconnectStandalone(
372
+ context,
373
+ adapter,
374
+ BluetoothProfile.A2DP,
375
+ device,
376
+ BluetoothA2dp::class.java,
377
+ "A2DP"
378
+ )
379
+ requestProfileDisconnectStandalone(
380
+ context,
381
+ adapter,
382
+ BluetoothProfile.HEADSET,
383
+ device,
384
+ BluetoothHeadset::class.java,
385
+ "HFP"
386
+ )
387
+ removeBondStandalone(device)
388
+ }
389
+ Handler(Looper.getMainLooper())
390
+ .postDelayed({ retryUnbondIfStillPaired(context, extraAddress) }, 700)
391
+ }
392
+
393
+ private fun retryUnbondIfStillPaired(context: Context?, extraAddress: String?) {
394
+ if (context == null) {
395
+ return
396
+ }
397
+ val adapter =
398
+ try {
399
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
400
+ ?.adapter
401
+ } catch (_: Exception) {
402
+ null
403
+ }
404
+ ?: return
405
+ val extra = extraAddress?.uppercase()
406
+ val leftover =
407
+ try {
408
+ adapter.bondedDevices?.toList().orEmpty()
409
+ } catch (_: Exception) {
410
+ emptyList()
411
+ }
412
+ .filter {
413
+ isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
414
+ (extra != null &&
415
+ it.address.equals(extra, ignoreCase = true))
416
+ }
417
+ if (leftover.isEmpty()) {
418
+ Bridge.log("LIVE: CTKD: unbond verify — no Mentra Live bonds remain")
419
+ return
420
+ }
421
+ for (device in leftover) {
422
+ Bridge.log(
423
+ "LIVE: CTKD: unbond retry still paired " +
424
+ safeBondedDeviceName(device) +
425
+ " (" +
426
+ device.address +
427
+ ") bondState=" +
428
+ device.bondState
429
+ )
430
+ removeBondStandalone(device)
431
+ }
432
+ }
433
+
143
434
  private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
144
435
 
145
436
  // Keep-alive parameters
@@ -251,6 +542,16 @@ class MentraLive : SGCManager() {
251
542
 
252
543
  private var reconnectAttempts = 0
253
544
  private var isReconnecting = false // Track if we're in reconnection mode
545
+ // Manual discovery/connect invalidates delayed reconnect work from an earlier GATT failure.
546
+ private var reconnectGeneration = 0
547
+ // Pair Glasses discovery is an explicit no-reconnect zone. It ends only when
548
+ // the user selects a scan result (connectById) or the manager is torn down.
549
+ private var manualDiscoveryActive = false
550
+ // Set by connectById() so a post-disconnect name scan actually GATT-connects.
551
+ // Discovery-only scans (findCompatibleDevices) leave this false so a leftover
552
+ // savedDeviceName cannot auto-connect a spoofed advertiser (iOS parity: iOS
553
+ // connects on saved-name match because connectById writes that name first).
554
+ private var explicitConnectByName = false
254
555
  /**
255
556
  * Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
256
557
  * so glasses can reboot.
@@ -274,18 +575,35 @@ class MentraLive : SGCManager() {
274
575
  private val fileProcessingHandler = Handler(fileProcessingThread.looper)
275
576
  private var scheduler: ScheduledExecutorService? = null
276
577
  private var isScanning = false
578
+ // Prevent an old scan timeout from stopping a newer manual scan.
579
+ private var scanGeneration = 0
277
580
  private var isConnecting = false
278
581
  private var isKilled = false
279
582
 
583
+ /** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
584
+ private var pairingYieldActive = false
585
+ private var pairingYieldEndRunnable: Runnable? = null
586
+ /** After yield ends, auth/not-owner failures may clear saved glasses. */
587
+ private var pairingYieldAwaitingReclaim = false
588
+ /** Hold GATT teardown until the unpair write can leave the phone. */
589
+ private var unpairFlushPending = false
590
+ private var unpairFlushRunnable: Runnable? = null
591
+
280
592
  // CTKD (Cross-Transport Key Derivation) support for BES devices
281
593
  private var isBondingReceiverRegistered = false
282
594
  private var isBtClassicConnected = false
283
595
  private var bondingReceiver: BroadcastReceiver? = null
284
596
  private var bondingRetryCount = 0
285
597
 
598
+ // Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
599
+ private var pairingTimingActive = false
600
+ private var pairingTimingStartMs = 0L
601
+ private var pairingTimingLastMs = 0L
602
+
286
603
  // A2DP profile connection for already-bonded devices
287
604
  private var a2dpProfile: BluetoothA2dp? = null
288
605
  private var isA2dpProxyRegistered = false
606
+ private var a2dpConnectAttempts = 0
289
607
 
290
608
  private data class OutgoingBleCommandTraceInfo(
291
609
  val commandType: String,
@@ -310,7 +628,11 @@ class MentraLive : SGCManager() {
310
628
  val queuedAtMs: Long
311
629
  )
312
630
 
313
- private data class QueuedBleWrite(val data: ByteArray, val trace: BleWriteTrace?)
631
+ private data class QueuedBleWrite(
632
+ val data: ByteArray,
633
+ val trace: BleWriteTrace?,
634
+ val sessionGeneration: Long
635
+ )
314
636
 
315
637
  private var sendQueue = ConcurrentLinkedQueue<QueuedBleWrite>()
316
638
  private val bleWriteTraceSequence = AtomicLong(1)
@@ -603,6 +925,9 @@ class MentraLive : SGCManager() {
603
925
  // Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
604
926
  private var glassesReadyReceived = false
605
927
  private var audioConnected = false
928
+ // CTKD/A2DP must wait until GATT notifications are armed; starting Classic while the
929
+ // BLE link is still discovering services commonly yields GATT status 19 on Samsung.
930
+ private var ctkdInitiatedThisGattSession = false
606
931
 
607
932
  // Micbeat tracking - periodically enable custom audio TX
608
933
  private var micBeatHandler = Handler(Looper.getMainLooper())
@@ -612,12 +937,12 @@ class MentraLive : SGCManager() {
612
937
  // Message tracking for reliable delivery
613
938
  private val pendingMessages = ConcurrentHashMap<Long, PendingMessage>()
614
939
  private val messageIdCounter = AtomicLong(1)
940
+ private val bleSessionGeneration = AtomicLong(0)
615
941
 
616
942
  // Esoteric message ID generation
617
943
  private val secureRandom = SecureRandom()
618
944
  private val deviceId = System.currentTimeMillis() xor Random().nextLong()
619
945
 
620
- private var lastReceivedLc3Sequence: Byte = -1
621
946
  private var lc3SequenceNumber: Byte = 0
622
947
  private var lc3DecoderPtr = 0L
623
948
  private var lc3AudioPlayer: Lc3Player? = null
@@ -637,7 +962,8 @@ class MentraLive : SGCManager() {
637
962
  val messageData: String,
638
963
  val timestamp: Long,
639
964
  val retryCount: Int,
640
- val retryRunnable: Runnable
965
+ val retryRunnable: Runnable,
966
+ val sessionGeneration: Long
641
967
  )
642
968
 
643
969
  private var lc3AudioFileStream: FileOutputStream? = null
@@ -803,10 +1129,14 @@ class MentraLive : SGCManager() {
803
1129
  }
804
1130
 
805
1131
  private fun updateConnectionState(state: String) {
1132
+ if (state == ConnTypes.DISCONNECTED) {
1133
+ DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
1134
+ }
806
1135
  val isEqual = state == connectionState
807
1136
  if (isEqual) {
808
1137
  if (state == ConnTypes.DISCONNECTED) {
809
1138
  resetWireNegotiationState()
1139
+ resetPendingAckState()
810
1140
  // Queued writes are session-bound: transmitting them into the NEXT session
811
1141
  // (e.g. a stale handshake whose reply activates v2 before the new session
812
1142
  // negotiated) is the bug class this clear removes. Higher layers re-send
@@ -824,6 +1154,7 @@ class MentraLive : SGCManager() {
824
1154
  // same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
825
1155
  DeviceStore.apply("glasses", "serialNumber", "")
826
1156
  DeviceStore.apply("glasses", "bluetoothMacAddress", "")
1157
+ DeviceStore.apply("glasses", "wifiMacAddress", "")
827
1158
  }
828
1159
 
829
1160
  // Actually update the connection state!
@@ -854,6 +1185,7 @@ class MentraLive : SGCManager() {
854
1185
  DeviceStore.apply("glasses", "signalStrength", -1)
855
1186
  DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
856
1187
  resetWireNegotiationState()
1188
+ resetPendingAckState()
857
1189
  sendQueue.clear() // see the disconnect reset above: stale writes die with the session
858
1190
 
859
1191
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
@@ -874,6 +1206,17 @@ class MentraLive : SGCManager() {
874
1206
  cachedOtaCurrentStep = 0
875
1207
  cachedOtaStepSequence = null
876
1208
  lastBesOtaProgress = -1
1209
+ // The bounded UART-ownership lease is transport state, not session-cache state. Keep it
1210
+ // through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
1211
+ }
1212
+
1213
+ private fun updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
1214
+ besOtaHeartbeatGuard.observeOtaStatus(
1215
+ stepType,
1216
+ phase,
1217
+ status,
1218
+ SystemClock.elapsedRealtime(),
1219
+ )
877
1220
  }
878
1221
 
879
1222
  protected fun setFontSizes() {
@@ -884,6 +1227,10 @@ class MentraLive : SGCManager() {
884
1227
 
885
1228
  /** Starts BLE scanning for Mentra Live glasses */
886
1229
  private fun startScan() {
1230
+ if (pairingYieldActive) {
1231
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
1232
+ return
1233
+ }
887
1234
  if (bluetoothAdapter == null || isScanning) {
888
1235
  return
889
1236
  }
@@ -942,6 +1289,7 @@ class MentraLive : SGCManager() {
942
1289
  )
943
1290
  }
944
1291
 
1292
+ val generation = ++scanGeneration
945
1293
  isScanning = true
946
1294
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
947
1295
 
@@ -949,6 +1297,9 @@ class MentraLive : SGCManager() {
949
1297
  handler.postDelayed(
950
1298
  object : Runnable {
951
1299
  override fun run() {
1300
+ if (generation != scanGeneration) {
1301
+ return
1302
+ }
952
1303
  if (isScanning) {
953
1304
  stopScan()
954
1305
  emitStopScanEvent()
@@ -992,11 +1343,13 @@ class MentraLive : SGCManager() {
992
1343
 
993
1344
  /** Stops BLE scanning */
994
1345
  override fun stopScan() {
1346
+ manualDiscoveryActive = false
995
1347
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
996
1348
  return
997
1349
  }
998
1350
 
999
1351
  try {
1352
+ scanGeneration++
1000
1353
  bluetoothScanner!!.stopScan(scanCallback)
1001
1354
  isScanning = false
1002
1355
  DeviceStore.apply("bluetooth", "searching", false)
@@ -1020,6 +1373,48 @@ class MentraLive : SGCManager() {
1020
1373
  Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
1021
1374
  }
1022
1375
 
1376
+ /**
1377
+ * A selected pairing target can stop advertising once its GATT link comes up, before
1378
+ * readiness promotes it to the default device. Re-emit only that explicit pending target;
1379
+ * an established owner's connected glasses have no pending name/address and stay hidden.
1380
+ */
1381
+ private fun emitConnectedPendingDeviceForPairingScan() {
1382
+ if (!manualDiscoveryActive || isReconnecting || pairingYieldActive) return
1383
+ val device = connectedDevice ?: bluetoothGatt?.device ?: return
1384
+ val name = safeBondedDeviceName(device)
1385
+ if (!isMentraLiveBluetoothName(name)) return
1386
+
1387
+ val pendingName = DeviceStore.get("bluetooth", "pending_device_name") as? String ?: ""
1388
+ val pendingAddress =
1389
+ DeviceStore.get("bluetooth", "pending_device_address") as? String ?: ""
1390
+ val pendingSecurePairingCapable =
1391
+ DeviceStore.get("bluetooth", "pending_device_secure_pairing_capable") as? Boolean
1392
+ val address = device.address ?: ""
1393
+ if (
1394
+ !shouldRecoverConnectedMentraLivePairingTarget(
1395
+ isConnected,
1396
+ name,
1397
+ address,
1398
+ pendingName,
1399
+ pendingAddress,
1400
+ )
1401
+ ) {
1402
+ return
1403
+ }
1404
+
1405
+ Bridge.log(
1406
+ "LIVE: Pairing scan: recovering connected pending target $name ($address)"
1407
+ )
1408
+ Bridge.sendDiscoveredDevice(
1409
+ DeviceTypes.LIVE,
1410
+ name,
1411
+ address,
1412
+ pairingMode = true,
1413
+ pairingCode = null,
1414
+ securePairingCapable = pendingSecurePairingCapable,
1415
+ )
1416
+ }
1417
+
1023
1418
  var seenDevices: MutableSet<String> = HashSet()
1024
1419
 
1025
1420
  /** BLE Scan callback */
@@ -1072,42 +1467,78 @@ class MentraLive : SGCManager() {
1072
1467
  deviceName.startsWith("MENTRA_LIVE_BT") ||
1073
1468
  deviceName.lowercase().startsWith("mentra_live")
1074
1469
  ) {
1470
+ // Connect-by-name only when reconnecting OR connectById() asked for
1471
+ // this name. A discovery-only pairing scan must not auto-connect just
1472
+ // because savedDeviceName still matches (spoof risk).
1473
+ val isNamedConnectTarget =
1474
+ savedDeviceName.isNotEmpty() &&
1475
+ savedDeviceName == deviceName &&
1476
+ (isReconnecting || explicitConnectByName)
1477
+ if (!isReconnecting &&
1478
+ advertisesPairingFlag(result) &&
1479
+ !isPairingDiscoverable(result)
1480
+ ) {
1481
+ // Tell RN a nearby unit is advertising but not pairable. The scan
1482
+ // screen keeps it visible with pairing-mode guidance but blocks the
1483
+ // connection until a pairable advertisement arrives.
1484
+ Bridge.log(
1485
+ "LIVE: Nearby $deviceName is secure firmware not in pairing mode — exposing as non-pairable"
1486
+ )
1487
+ Bridge.sendDiscoveredDevice(
1488
+ DeviceTypes.LIVE,
1489
+ deviceName,
1490
+ deviceAddress,
1491
+ result.rssi,
1492
+ pairingMode = false,
1493
+ pairingCode = null,
1494
+ securePairingCapable = true,
1495
+ )
1496
+ return
1497
+ }
1075
1498
  val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
1499
+ val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
1076
1500
  Bridge.log(
1077
1501
  "LIVE: Found compatible " +
1078
1502
  glassType +
1079
1503
  " glasses device: " +
1080
- deviceName
1504
+ deviceName +
1505
+ if (secureCapable) " (pairingMode=$pairingMode)"
1506
+ else " (legacy firmware, pairable)"
1081
1507
  )
1082
- // EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
1083
- // smartGlassesDevice.deviceModelName, deviceName));
1084
1508
  Bridge.sendDiscoveredDevice(
1085
1509
  DeviceTypes.LIVE,
1086
1510
  deviceName,
1087
1511
  deviceAddress,
1088
- result.rssi
1512
+ result.rssi,
1513
+ // Field firmware has no pairing flag. Treat it as pairable so the
1514
+ // new Mentra App can still find old glasses.
1515
+ pairingMode = if (secureCapable) pairingMode else true,
1516
+ pairingCode = pairingCode,
1517
+ securePairingCapable = secureCapable,
1089
1518
  )
1090
1519
 
1091
- // If this is the specific device we want to connect to by name, connect to
1092
- // it
1093
- if (savedDeviceName != null && savedDeviceName == deviceName) {
1520
+ // connectById / reconnect: user (or reconnect) asked for this name
1521
+ // GATT-connect. Discovery-only scans never set explicitConnectByName.
1522
+ if (isNamedConnectTarget) {
1094
1523
  Log.i(
1095
1524
  TAG,
1096
- "🔌 🎯 RECONNECT TARGET FOUND - Device: " +
1525
+ "🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
1097
1526
  deviceName +
1098
1527
  " (Attempt #" +
1099
1528
  reconnectAttempts +
1100
1529
  ")"
1101
1530
  )
1102
1531
  Bridge.log(
1103
- "LIVE: 🔌 🎯 Found our remembered device by name, connecting: " +
1532
+ "LIVE: 🔌 🎯 Found target device by name, connecting: " +
1104
1533
  deviceName +
1105
- " (Reconnect attempt #" +
1106
- reconnectAttempts +
1107
- ")"
1534
+ (if (isReconnecting)
1535
+ " (Reconnect attempt #" +
1536
+ reconnectAttempts +
1537
+ ")"
1538
+ else " (connectById)")
1108
1539
  )
1109
1540
  synchronized(connectionLock) {
1110
- if (isConnected || isConnecting) {
1541
+ if (pairingYieldActive || isConnected || isConnecting) {
1111
1542
  return
1112
1543
  }
1113
1544
  isConnecting = true
@@ -1115,6 +1546,7 @@ class MentraLive : SGCManager() {
1115
1546
  stopScan()
1116
1547
  emitStopScanEvent()
1117
1548
  isReconnecting = false
1549
+ explicitConnectByName = false
1118
1550
  connectToDevice(device)
1119
1551
  }
1120
1552
  }
@@ -1176,11 +1608,67 @@ class MentraLive : SGCManager() {
1176
1608
  }
1177
1609
  }
1178
1610
 
1611
+ /**
1612
+ * Reject callbacks delivered by a GATT object from an earlier connection attempt. Without
1613
+ * this identity check, a late disconnect can tear down the active connection and clear its
1614
+ * session-bound send state.
1615
+ */
1616
+ private fun isCurrentGattCallback(gatt: BluetoothGatt): Boolean {
1617
+ if (gatt === bluetoothGatt) {
1618
+ return true
1619
+ }
1620
+
1621
+ Log.w(TAG, "🔌 Ignoring callback from stale BluetoothGatt")
1622
+ Bridge.log("LIVE: 🔌 Ignoring callback from stale BluetoothGatt session")
1623
+ try {
1624
+ gatt.close()
1625
+ } catch (e: Exception) {
1626
+ Log.w(TAG, "🔌 Failed to close stale BluetoothGatt: " + e)
1627
+ }
1628
+ return false
1629
+ }
1630
+
1631
+ private fun beginPairingTiming(reason: String) {
1632
+ pairingTimingStartMs = SystemClock.elapsedRealtime()
1633
+ pairingTimingLastMs = pairingTimingStartMs
1634
+ pairingTimingActive = true
1635
+ Bridge.log("LIVE: PAIRING_TIMING begin reason=$reason sinceStartMs=0 deltaMs=0")
1636
+ }
1637
+
1638
+ private fun markPairingTiming(checkpoint: String, detail: String = "") {
1639
+ if (!pairingTimingActive) {
1640
+ return
1641
+ }
1642
+ val now = SystemClock.elapsedRealtime()
1643
+ val sinceStart = now - pairingTimingStartMs
1644
+ val delta = now - pairingTimingLastMs
1645
+ pairingTimingLastMs = now
1646
+ val extra = if (detail.isEmpty()) "" else " $detail"
1647
+ Bridge.log(
1648
+ "LIVE: PAIRING_TIMING checkpoint=$checkpoint sinceStartMs=$sinceStart deltaMs=$delta$extra"
1649
+ )
1650
+ }
1651
+
1652
+ private fun endPairingTiming(checkpoint: String, detail: String = "") {
1653
+ markPairingTiming(checkpoint, detail)
1654
+ pairingTimingActive = false
1655
+ }
1656
+
1179
1657
  /** Connect to a specific BLE device */
1180
1658
  private fun connectToDevice(device: BluetoothDevice?) {
1181
1659
  if (device == null) {
1182
1660
  return
1183
1661
  }
1662
+ if (pairingYieldActive) {
1663
+ Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1664
+ isConnecting = false
1665
+ isReconnecting = false
1666
+ return
1667
+ }
1668
+
1669
+ beginPairingTiming(
1670
+ "connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
1671
+ )
1184
1672
 
1185
1673
  // Cancel any previous connection timeouts
1186
1674
  if (connectionTimeoutRunnable != null) {
@@ -1280,6 +1768,85 @@ class MentraLive : SGCManager() {
1280
1768
  // }
1281
1769
  // }
1282
1770
 
1771
+ private fun enterPairingYield(windowMs: Long) {
1772
+ pairingYieldActive = true
1773
+ pairingYieldAwaitingReclaim = false
1774
+ isReconnecting = false
1775
+ isConnecting = false
1776
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1777
+ if (connectionTimeoutRunnable != null) {
1778
+ connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
1779
+ connectionTimeoutRunnable = null
1780
+ }
1781
+ // Stop scans/reconnect work; keep default_wearable / Classic bond.
1782
+ if (isScanning) {
1783
+ stopScan()
1784
+ emitStopScanEvent()
1785
+ }
1786
+ // Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
1787
+ // Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
1788
+ val yieldingDevice = connectedDevice
1789
+ disconnectClassicProfiles(yieldingDevice)
1790
+ // closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
1791
+ // onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
1792
+ // Mirror a real disconnect for UI + session state, then tear GATT down.
1793
+ isConnected = false
1794
+ connectedDevice = null
1795
+ glassesReady = false
1796
+ glassesSessionId = null
1797
+ readinessCompletedThisBleSession = false
1798
+ glassesReadyReceived = false
1799
+ ctkdInitiatedThisGattSession = false
1800
+ audioConnected = false
1801
+ notificationsEnabled = false
1802
+ processSendQueueRunnable?.let { handler.removeCallbacks(it) }
1803
+ stopReadinessCheckLoop()
1804
+ stopHeartbeat()
1805
+ stopSignalStrengthPolling()
1806
+ stopMicBeat()
1807
+ closeL2capFileChannel()
1808
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1809
+ clearFilePacketBuffer()
1810
+ updateConnectionState(ConnTypes.DISCONNECTED)
1811
+ closeGattQuietly(true)
1812
+ // Stand down for the whole window. Probing GATT during yield races the new
1813
+ // phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
1814
+ val end =
1815
+ Runnable {
1816
+ Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
1817
+ pairingYieldActive = false
1818
+ pairingYieldAwaitingReclaim = true
1819
+ pairingYieldEndRunnable = null
1820
+ if (!isKilled && !isConnected && !isConnecting) {
1821
+ handleReconnection()
1822
+ }
1823
+ }
1824
+ pairingYieldEndRunnable = end
1825
+ handler.postDelayed(end, windowMs)
1826
+ }
1827
+
1828
+ fun isPairingYieldActive(): Boolean = pairingYieldActive
1829
+
1830
+ private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
1831
+ Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
1832
+ pairingYieldAwaitingReclaim = false
1833
+ pairingYieldActive = false
1834
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
1835
+ pairingYieldEndRunnable = null
1836
+ try {
1837
+ forget()
1838
+ } catch (e: Exception) {
1839
+ Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
1840
+ }
1841
+ DeviceStore.apply("bluetooth", "default_wearable", "")
1842
+ DeviceStore.apply("bluetooth", "device_name", "")
1843
+ DeviceStore.apply("bluetooth", "device_address", "")
1844
+ Bridge.saveSetting("default_wearable", "")
1845
+ Bridge.saveSetting("device_name", "")
1846
+ Bridge.saveSetting("device_address", "")
1847
+ Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
1848
+ }
1849
+
1283
1850
  /** Handle reconnection with exponential backoff */
1284
1851
  private fun handleReconnection() {
1285
1852
  // Don't attempt reconnection if we've been killed/forgotten
@@ -1289,6 +1856,20 @@ class MentraLive : SGCManager() {
1289
1856
  return
1290
1857
  }
1291
1858
 
1859
+ if (manualDiscoveryActive) {
1860
+ Bridge.log(
1861
+ "LIVE: 🔌 RECONNECT ABORTED - Pair Glasses discovery is active"
1862
+ )
1863
+ isReconnecting = false
1864
+ return
1865
+ }
1866
+
1867
+ if (pairingYieldActive) {
1868
+ Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
1869
+ isReconnecting = false
1870
+ return
1871
+ }
1872
+
1292
1873
  if (isReconnecting) {
1293
1874
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
1294
1875
  return
@@ -1360,10 +1941,25 @@ class MentraLive : SGCManager() {
1360
1941
  "ms)"
1361
1942
  )
1362
1943
 
1363
- // Schedule reconnection attempt
1944
+ // Schedule reconnection attempt. A later manual scan/connect invalidates this
1945
+ // callback so stale saved identity cannot hijack user-initiated discovery.
1946
+ val generation = reconnectGeneration
1364
1947
  handler.postDelayed(
1365
1948
  object : Runnable {
1366
1949
  override fun run() {
1950
+ if (generation != reconnectGeneration) {
1951
+ Bridge.log(
1952
+ "LIVE: 🔌 RECONNECT ABORTED - superseded by manual discovery"
1953
+ )
1954
+ return
1955
+ }
1956
+ if (pairingYieldActive) {
1957
+ isReconnecting = false
1958
+ Bridge.log(
1959
+ "LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
1960
+ )
1961
+ return
1962
+ }
1367
1963
  if (!isConnected && !isConnecting && !isKilled) {
1368
1964
  // Prefer saved MAC for direct GATT connect (faster and more reliable
1369
1965
  // than scanning).
@@ -1498,6 +2094,10 @@ class MentraLive : SGCManager() {
1498
2094
  status: Int,
1499
2095
  newState: Int
1500
2096
  ) {
2097
+ if (!isCurrentGattCallback(gatt)) {
2098
+ return
2099
+ }
2100
+
1501
2101
  // Cancel the connection timeout
1502
2102
  if (connectionTimeoutRunnable != null) {
1503
2103
  connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
@@ -1509,9 +2109,11 @@ class MentraLive : SGCManager() {
1509
2109
  Bridge.log(
1510
2110
  "LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
1511
2111
  )
2112
+ markPairingTiming("gatt_connected")
1512
2113
  isConnecting = false
1513
2114
  isConnected = true
1514
2115
  connectedDevice = gatt.device
2116
+ emitConnectedPendingDeviceForPairingScan()
1515
2117
 
1516
2118
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
1517
2119
  // Persist MAC so reconnection can use direct GATT instead of scanning
@@ -1536,42 +2138,27 @@ class MentraLive : SGCManager() {
1536
2138
  // connectedDevice.getName());
1537
2139
  // }
1538
2140
 
1539
- // CTKD Implementation: Register bonding receiver and create bond for BT
1540
- // Classic
2141
+ // Register early so we don't miss bond broadcasts, but defer
2142
+ // createBond / A2DP until BLE GATT setup finishes (see
2143
+ // maybeInitiateCtkdAudioAfterBleReady).
1541
2144
  registerBondingReceiver()
1542
- bondingRetryCount = 0 // Reset retry counter for new connection
2145
+ bondingRetryCount = 0
2146
+ ctkdInitiatedThisGattSession = false
1543
2147
  Bridge.log(
1544
- "LIVE: CTKD: BLE connection established, initiating CTKD bonding for BT Classic"
2148
+ "LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
2149
+ )
2150
+ markPairingTiming(
2151
+ "ctkd_deferred",
2152
+ "bondState=${connectedDevice!!.bondState}"
1545
2153
  )
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
2154
 
1562
2155
  // Discover services
1563
2156
  gatt.discoverServices()
1564
2157
 
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
- )
2158
+ // Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
2159
+ // followed by status 19 was zeroing the counter every ~1s and
2160
+ // preventing exponential backoff. Reset at ble_chars_ready instead.
2161
+ isReconnecting = false
1575
2162
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1576
2163
  Log.w(
1577
2164
  TAG,
@@ -1580,6 +2167,10 @@ class MentraLive : SGCManager() {
1580
2167
  Bridge.log(
1581
2168
  "LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
1582
2169
  )
2170
+ endPairingTiming(
2171
+ "gatt_disconnected",
2172
+ "queueSize=${sendQueue.size}"
2173
+ )
1583
2174
  isConnected = false
1584
2175
  isConnecting = false
1585
2176
 
@@ -1590,6 +2181,7 @@ class MentraLive : SGCManager() {
1590
2181
 
1591
2182
  // Reset audio pairing flags
1592
2183
  glassesReadyReceived = false
2184
+ ctkdInitiatedThisGattSession = false
1593
2185
  audioConnected = false
1594
2186
 
1595
2187
  notificationsEnabled = false
@@ -1648,6 +2240,10 @@ class MentraLive : SGCManager() {
1648
2240
  status +
1649
2241
  ") - Will retry reconnection"
1650
2242
  )
2243
+ endPairingTiming(
2244
+ "gatt_error",
2245
+ "status=$status queueSize=${sendQueue.size}"
2246
+ )
1651
2247
  isConnected = false
1652
2248
  isConnecting = false
1653
2249
  glassesReady = false
@@ -1655,6 +2251,7 @@ class MentraLive : SGCManager() {
1655
2251
  readinessCompletedThisBleSession = false
1656
2252
  glassesReadyReceived = false
1657
2253
  audioConnected = false
2254
+ ctkdInitiatedThisGattSession = false
1658
2255
 
1659
2256
  notificationsEnabled = false
1660
2257
 
@@ -1678,6 +2275,19 @@ class MentraLive : SGCManager() {
1678
2275
  // Clean up resources
1679
2276
  closeGattQuietly(false)
1680
2277
 
2278
+ // Auth/not-owner after pairing yield — not a generic RF blip.
2279
+ // GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
2280
+ // not wipe the saved owner.
2281
+ if (pairingYieldAwaitingReclaim &&
2282
+ (status == 5 ||
2283
+ status == 15 ||
2284
+ status == 0x05 ||
2285
+ status == 0x0F)
2286
+ ) {
2287
+ clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
2288
+ return
2289
+ }
2290
+
1681
2291
  // Attempt reconnection if not killed
1682
2292
  if (!isKilled) {
1683
2293
  Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
@@ -1728,24 +2338,47 @@ class MentraLive : SGCManager() {
1728
2338
  txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
1729
2339
  rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
1730
2340
 
1731
- // Get LC3 characteristics (always supported)
2341
+ // LC3 audio is optional. Older firmware can still run the core session
2342
+ // and receive an OTA without these characteristics.
1732
2343
  lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
1733
2344
  lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
1734
2345
 
1735
- // Check if we have required characteristics
2346
+ // Match iOS: only the core transport pair is required for readiness.
1736
2347
  val hasRequiredCharacteristics =
1737
- (rxCharacteristic != null && txCharacteristic != null) &&
1738
- (lc3ReadCharacteristic != null &&
1739
- lc3WriteCharacteristic != null)
2348
+ hasRequiredMentraLiveCoreCharacteristics(
2349
+ hasRxCharacteristic = rxCharacteristic != null,
2350
+ hasTxCharacteristic = txCharacteristic != null,
2351
+ )
2352
+ val hasLc3AudioCharacteristics =
2353
+ lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
1740
2354
 
1741
2355
  if (hasRequiredCharacteristics) {
2356
+ if (!hasLc3AudioCharacteristics) {
2357
+ Bridge.log(
2358
+ "LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
2359
+ )
2360
+ }
1742
2361
  // BLE connection established, but we still need to wait for glasses
1743
2362
  // SOC
1744
2363
  Bridge.log(
1745
- "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX + LC3 TX/RX characteristics verified)"
2364
+ "LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
2365
+ hasLc3AudioCharacteristics +
2366
+ ")"
1746
2367
  )
2368
+ markPairingTiming("ble_chars_ready")
1747
2369
  Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1748
2370
 
2371
+ // GATT stack reached real readiness — safe to clear reconnect
2372
+ // backoff.
2373
+ val previousAttempts = reconnectAttempts
2374
+ reconnectAttempts = 0
2375
+ Log.i(
2376
+ TAG,
2377
+ "🔌 ✅ Reconnection counter reset at ble_chars_ready (was at " +
2378
+ previousAttempts +
2379
+ " attempts)"
2380
+ )
2381
+
1749
2382
  // Don't set connected=true here - wait for SOC to be ready
1750
2383
  // (fullyBooted=true)
1751
2384
  // DeviceStore handles connected state based on fullyBooted
@@ -1786,13 +2419,6 @@ class MentraLive : SGCManager() {
1786
2419
  if (txCharacteristic == null) {
1787
2420
  Log.e(TAG, "TX characteristic (peripheral's RX) not found")
1788
2421
  }
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
2422
  gatt.disconnect()
1797
2423
  }
1798
2424
  } else {
@@ -1899,6 +2525,10 @@ class MentraLive : SGCManager() {
1899
2525
  gatt: BluetoothGatt,
1900
2526
  characteristic: BluetoothGattCharacteristic
1901
2527
  ) {
2528
+ if (!isCurrentGattCallback(gatt)) {
2529
+ return
2530
+ }
2531
+
1902
2532
  // Get thread ID for tracking thread issues
1903
2533
  val threadId = Thread.currentThread().id
1904
2534
  val uuid = characteristic.uuid
@@ -1932,20 +2562,6 @@ class MentraLive : SGCManager() {
1932
2562
 
1933
2563
  if (isRxCharacteristic) {
1934
2564
  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
2565
  } else if (isTxCharacteristic) {
1950
2566
  Bridge.log("LIVE: Received data on TX characteristic")
1951
2567
  } else if (isLc3ReadCharacteristic) {
@@ -2050,6 +2666,9 @@ class MentraLive : SGCManager() {
2050
2666
  startSignalStrengthPolling()
2051
2667
  handler.post(processSendQueueRunnable!!)
2052
2668
  startReadinessCheckLoop()
2669
+ // Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
2670
+ // collide during service discovery / descriptor writes.
2671
+ maybeInitiateCtkdAudioAfterBleReady()
2053
2672
  return
2054
2673
  }
2055
2674
 
@@ -2310,8 +2929,20 @@ class MentraLive : SGCManager() {
2310
2929
  return
2311
2930
  }
2312
2931
 
2313
- // Send the next item from the queue
2314
- val queuedWrite = sendQueue.poll()
2932
+ // Send the next item from this BLE session. A retry can race disconnect teardown and land
2933
+ // after sendQueue.clear(); the generation check is the deterministic backstop.
2934
+ var queuedWrite = sendQueue.poll()
2935
+ val currentGeneration = bleSessionGeneration.get()
2936
+ while (queuedWrite != null && queuedWrite.sessionGeneration != currentGeneration) {
2937
+ Bridge.log(
2938
+ "LIVE: Dropping stale queued write from BLE session " +
2939
+ queuedWrite.sessionGeneration +
2940
+ " (current: " +
2941
+ currentGeneration +
2942
+ ")"
2943
+ )
2944
+ queuedWrite = sendQueue.poll()
2945
+ }
2315
2946
  if (queuedWrite != null) {
2316
2947
  // Update last send time before sending
2317
2948
  lastSendTimeMs = currentTimeMs
@@ -2338,25 +2969,35 @@ class MentraLive : SGCManager() {
2338
2969
 
2339
2970
  /** Send data through BLE */
2340
2971
  private fun sendDataInternal(write: QueuedBleWrite?) {
2341
- if (!isConnected || bluetoothGatt == null || txCharacteristic == null || write == null) {
2972
+ if (!isConnected || write == null) {
2973
+ return
2974
+ }
2975
+ if (write.sessionGeneration != bleSessionGeneration.get()) {
2976
+ Bridge.log("LIVE: Dropping stale BLE write before GATT transmission")
2977
+ return
2978
+ }
2979
+
2980
+ val gatt = bluetoothGatt
2981
+ val characteristic = txCharacteristic
2982
+ if (gatt == null || characteristic == null) {
2342
2983
  return
2343
2984
  }
2344
2985
 
2345
2986
  try {
2346
2987
  val writeStartedAtMs = System.currentTimeMillis()
2347
- txCharacteristic!!.value = write.data
2988
+ characteristic.value = write.data
2348
2989
  inFlightBleWriteTrace = write.trace
2349
2990
  inFlightBleWriteStartedAtMs = writeStartedAtMs
2350
- val writeAccepted = bluetoothGatt!!.writeCharacteristic(txCharacteristic)
2991
+ val writeAccepted = gatt.writeCharacteristic(characteristic)
2351
2992
  logBleWriteTrace(
2352
2993
  "write_call",
2353
2994
  write.trace,
2354
2995
  mapOf(
2355
2996
  "writeAccepted" to writeAccepted,
2356
2997
  "currentMtu" to currentMtu,
2357
- "writeType" to txCharacteristic!!.writeType,
2998
+ "writeType" to characteristic.writeType,
2358
2999
  "queueSize" to sendQueue.size,
2359
- "characteristicUuid" to txCharacteristic!!.uuid.toString()
3000
+ "characteristicUuid" to characteristic.uuid.toString()
2360
3001
  )
2361
3002
  )
2362
3003
  if (!writeAccepted) {
@@ -2379,7 +3020,11 @@ class MentraLive : SGCManager() {
2379
3020
  }
2380
3021
 
2381
3022
  /** Queue data to be sent */
2382
- private fun queueData(data: ByteArray?, trace: BleWriteTrace? = null) {
3023
+ private fun queueData(
3024
+ data: ByteArray?,
3025
+ trace: BleWriteTrace? = null,
3026
+ sessionGeneration: Long = bleSessionGeneration.get()
3027
+ ) {
2383
3028
  if (data != null) {
2384
3029
  val queuedTrace =
2385
3030
  trace?.copy(
@@ -2388,7 +3033,7 @@ class MentraLive : SGCManager() {
2388
3033
  trace.queuedAtMs
2389
3034
  else System.currentTimeMillis()
2390
3035
  )
2391
- sendQueue.add(QueuedBleWrite(data, queuedTrace))
3036
+ sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
2392
3037
  logBleChunkTrace(
2393
3038
  "queued",
2394
3039
  queuedTrace,
@@ -2432,6 +3077,7 @@ class MentraLive : SGCManager() {
2432
3077
  private fun sendJson(json: JSONObject?, wakeup: Boolean) {
2433
3078
  if (json != null) {
2434
3079
  try {
3080
+ val sessionGeneration = bleSessionGeneration.get()
2435
3081
  if (buildNumberInt < 5) {
2436
3082
  val jsonStr = json.toString()
2437
3083
  // Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
@@ -2447,7 +3093,7 @@ class MentraLive : SGCManager() {
2447
3093
  json,
2448
3094
  jsonStr.length
2449
3095
  )
2450
- sendDataToGlasses(jsonStr, wakeup)
3096
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2451
3097
  } else {
2452
3098
  // Add esoteric message ID to the JSON
2453
3099
  val messageId = generateEsotericMessageId()
@@ -2489,7 +3135,7 @@ class MentraLive : SGCManager() {
2489
3135
  }
2490
3136
 
2491
3137
  // Track the message for ACK with appropriate timeout
2492
- trackMessageForAck(messageId, jsonStr, ackTimeout)
3138
+ trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
2493
3139
 
2494
3140
  // Send the data
2495
3141
  if ("take_photo" == json.optString("type", "")) {
@@ -2508,7 +3154,7 @@ class MentraLive : SGCManager() {
2508
3154
  json,
2509
3155
  jsonStr.length
2510
3156
  )
2511
- sendDataToGlasses(jsonStr, wakeup)
3157
+ sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
2512
3158
  }
2513
3159
  } catch (e: JSONException) {
2514
3160
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -2528,14 +3174,14 @@ class MentraLive : SGCManager() {
2528
3174
  return list
2529
3175
  }
2530
3176
 
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
3177
  /** Track a message for ACK response with custom timeout */
2537
- private fun trackMessageForAck(messageId: Long, messageData: String, timeoutMs: Long) {
2538
- if (!isConnected) {
3178
+ private fun trackMessageForAck(
3179
+ messageId: Long,
3180
+ messageData: String,
3181
+ timeoutMs: Long,
3182
+ sessionGeneration: Long
3183
+ ) {
3184
+ if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
2539
3185
  Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
2540
3186
  return
2541
3187
  }
@@ -2552,25 +3198,50 @@ class MentraLive : SGCManager() {
2552
3198
  }
2553
3199
 
2554
3200
  // Create retry runnable
2555
- val retryRunnable = Runnable { retryMessage(messageId) }
3201
+ val retryRunnable = Runnable { retryMessage(messageId, sessionGeneration) }
2556
3202
 
2557
3203
  // Create pending message
2558
3204
  val pendingMessage =
2559
- PendingMessage(messageData, System.currentTimeMillis(), 0, retryRunnable)
3205
+ PendingMessage(
3206
+ messageData,
3207
+ System.currentTimeMillis(),
3208
+ 0,
3209
+ retryRunnable,
3210
+ sessionGeneration
3211
+ )
2560
3212
  pendingMessages[messageId] = pendingMessage
2561
3213
 
2562
3214
  // Schedule ACK timeout with custom timeout
2563
- handler.postDelayed({ checkMessageAck(messageId) }, timeoutMs)
3215
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
2564
3216
 
2565
3217
  Bridge.log(
2566
3218
  "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
2567
3219
  )
2568
3220
  }
2569
3221
 
3222
+ /**
3223
+ * Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
3224
+ * the old generation, and queued retries retain it, so a retry racing this clear cannot become
3225
+ * valid in the next session.
3226
+ */
3227
+ private fun resetPendingAckState() {
3228
+ val nextGeneration = bleSessionGeneration.incrementAndGet()
3229
+ val pendingCount = pendingMessages.size
3230
+ pendingMessages.clear()
3231
+ if (pendingCount > 0) {
3232
+ Bridge.log(
3233
+ "LIVE: Cleared $pendingCount pending ACK message(s); BLE session is now $nextGeneration"
3234
+ )
3235
+ }
3236
+ }
3237
+
2570
3238
  /** Check if a message has been acknowledged */
2571
- private fun checkMessageAck(messageId: Long) {
3239
+ private fun checkMessageAck(messageId: Long, sessionGeneration: Long) {
2572
3240
  val pendingMessage = pendingMessages[messageId]
2573
- if (pendingMessage != null) {
3241
+ if (pendingMessage != null &&
3242
+ pendingMessage.sessionGeneration == sessionGeneration &&
3243
+ sessionGeneration == bleSessionGeneration.get()
3244
+ ) {
2574
3245
  Log.w(
2575
3246
  TAG,
2576
3247
  "⏰ ACK timeout for message " +
@@ -2591,7 +3262,7 @@ class MentraLive : SGCManager() {
2591
3262
  MAX_RETRY_ATTEMPTS +
2592
3263
  ")"
2593
3264
  )
2594
- retryMessage(messageId)
3265
+ retryMessage(messageId, sessionGeneration)
2595
3266
  } else {
2596
3267
  // Max retries reached
2597
3268
  Log.e(
@@ -2602,22 +3273,31 @@ class MentraLive : SGCManager() {
2602
3273
  MAX_RETRY_ATTEMPTS +
2603
3274
  " attempts"
2604
3275
  )
2605
- pendingMessages.remove(messageId)
3276
+ pendingMessages.remove(messageId, pendingMessage)
2606
3277
  }
3278
+ } else if (pendingMessage != null && pendingMessage.sessionGeneration == sessionGeneration) {
3279
+ pendingMessages.remove(messageId, pendingMessage)
2607
3280
  }
2608
3281
  }
2609
3282
 
2610
3283
  /** Retry a message */
2611
- private fun retryMessage(messageId: Long) {
3284
+ private fun retryMessage(messageId: Long, sessionGeneration: Long) {
2612
3285
  val pendingMessage = pendingMessages[messageId]
2613
3286
  if (pendingMessage == null) {
2614
3287
  Log.w(TAG, "Message " + messageId + " no longer tracked for retry")
2615
3288
  return
2616
3289
  }
3290
+ if (pendingMessage.sessionGeneration != sessionGeneration ||
3291
+ sessionGeneration != bleSessionGeneration.get()
3292
+ ) {
3293
+ pendingMessages.remove(messageId, pendingMessage)
3294
+ Bridge.log("LIVE: Dropping stale ACK retry for message $messageId")
3295
+ return
3296
+ }
2617
3297
 
2618
3298
  if (pendingMessage.retryCount >= MAX_RETRY_ATTEMPTS) {
2619
3299
  Log.e(TAG, "Max retries reached for message " + messageId)
2620
- pendingMessages.remove(messageId)
3300
+ pendingMessages.remove(messageId, pendingMessage)
2621
3301
  return
2622
3302
  }
2623
3303
 
@@ -2627,11 +3307,18 @@ class MentraLive : SGCManager() {
2627
3307
  pendingMessage.messageData,
2628
3308
  System.currentTimeMillis(),
2629
3309
  pendingMessage.retryCount + 1,
2630
- pendingMessage.retryRunnable
3310
+ pendingMessage.retryRunnable,
3311
+ sessionGeneration
2631
3312
  )
2632
3313
 
2633
- // Update the tracked message
2634
- pendingMessages[messageId] = retryMessage
3314
+ // Replace only the entry inspected above. An ACK or disconnect may have removed it.
3315
+ if (!pendingMessages.replace(messageId, pendingMessage, retryMessage)) {
3316
+ return
3317
+ }
3318
+ if (sessionGeneration != bleSessionGeneration.get()) {
3319
+ pendingMessages.remove(messageId, retryMessage)
3320
+ return
3321
+ }
2635
3322
 
2636
3323
  // Send the message again
2637
3324
  Bridge.log(
@@ -2641,10 +3328,10 @@ class MentraLive : SGCManager() {
2641
3328
  retryMessage.retryCount +
2642
3329
  ")"
2643
3330
  )
2644
- sendDataToGlasses(pendingMessage.messageData, false)
3331
+ sendDataToGlasses(pendingMessage.messageData, false, sessionGeneration)
2645
3332
 
2646
3333
  // Schedule next ACK check
2647
- handler.postDelayed({ checkMessageAck(messageId) }, ACK_TIMEOUT_MS)
3334
+ handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, ACK_TIMEOUT_MS)
2648
3335
  }
2649
3336
 
2650
3337
  /** Process ACK response from glasses */
@@ -2955,25 +3642,6 @@ class MentraLive : SGCManager() {
2955
3642
  processJsonMessage(expanded)
2956
3643
  } else {
2957
3644
  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
3645
  }
2978
3646
 
2979
3647
  return // Exit after processing K900 protocol format
@@ -3147,6 +3815,24 @@ class MentraLive : SGCManager() {
3147
3815
  "pong" ->
3148
3816
  // Process heartbeat pong response
3149
3817
  Bridge.log("LIVE: Received pong response - connection healthy")
3818
+ "entering_pairing_mode" -> {
3819
+ val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
3820
+ Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
3821
+ enterPairingYield(windowMs)
3822
+ val body = HashMap<String, Any>()
3823
+ body["window_ms"] = windowMs
3824
+ body["reason"] = json.optString("reason", "user_gesture")
3825
+ Bridge.sendTypedMessage("entering_pairing_mode", body)
3826
+ }
3827
+ "pairing_info" ->
3828
+ Bridge.sendPairingInfo(
3829
+ json.optBoolean("had_previous_bond", false),
3830
+ jsonNullableString(json, "pairing_code"),
3831
+ json.optBoolean("classic_bond_ready", false),
3832
+ // Legacy firmware that omits this field is not secure-capable.
3833
+ json.optBoolean("secure_pairing_capable", false),
3834
+ json.optInt("protocol_version", 1),
3835
+ )
3150
3836
  "imu_response",
3151
3837
  "imu_stream_response",
3152
3838
  "imu_gesture_response",
@@ -3328,6 +4014,7 @@ class MentraLive : SGCManager() {
3328
4014
  if (osStepSequence != null && osStepSequence.length() > 0) {
3329
4015
  cachedOtaStepSequence = osStepSequence
3330
4016
  }
4017
+ updateBesOtaHeartbeatGuard(osStepType, osPhase, osStatus)
3331
4018
 
3332
4019
  Bridge.log(
3333
4020
  "LIVE: 📱 OTA status - step " +
@@ -3378,6 +4065,7 @@ class MentraLive : SGCManager() {
3378
4065
  } else {
3379
4066
  unified = "in_progress"
3380
4067
  }
4068
+ updateBesOtaHeartbeatGuard(currentUpdate, legacyPhase, unified)
3381
4069
  Bridge.log(
3382
4070
  "LIVE: 📱 Legacy ota_progress → ota_status: " +
3383
4071
  legacyStage +
@@ -3533,7 +4221,19 @@ class MentraLive : SGCManager() {
3533
4221
  }
3534
4222
  "glasses_ready" -> {
3535
4223
  // Glasses SOC has booted and is ready for communication
4224
+ if (pairingYieldActive && !isConnected) {
4225
+ Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
4226
+ return
4227
+ }
3536
4228
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
4229
+ pairingYieldAwaitingReclaim = false
4230
+ pairingYieldActive = false
4231
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
4232
+ pairingYieldEndRunnable = null
4233
+ markPairingTiming(
4234
+ "glasses_ready",
4235
+ "audioConnected=$audioConnected queueSize=${sendQueue.size}"
4236
+ )
3537
4237
 
3538
4238
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
3539
4239
  // onTransportReset() before sending it, so their side is back on legacy
@@ -3552,9 +4252,9 @@ class MentraLive : SGCManager() {
3552
4252
  // Set the ready flag to stop any future readiness checks
3553
4253
  glassesReady = true
3554
4254
  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.
4255
+ // Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
4256
+ // fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
4257
+ // for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
3558
4258
 
3559
4259
  // Stop the readiness check loop since we got confirmation
3560
4260
  stopReadinessCheckLoop()
@@ -3674,19 +4374,21 @@ class MentraLive : SGCManager() {
3674
4374
  Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
3675
4375
  }
3676
4376
 
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) {
4377
+ // Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
4378
+ // Do not wait for CTKD here iOS already marks ready on glasses_ready alone.
4379
+ endPairingTiming(
4380
+ "fully_connected",
4381
+ "via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
4382
+ )
4383
+ DeviceStore.apply("glasses", "fullyBooted", true)
4384
+ updateConnectionState(ConnTypes.CONNECTED)
4385
+ if (!audioConnected) {
3682
4386
  Bridge.log(
3683
- "LIVE: Audio: Both glasses_ready and audio connected - marking as fully connected"
4387
+ "LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
3684
4388
  )
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"
4389
+ markPairingTiming(
4390
+ "waiting_ctkd_bond",
4391
+ "queueSize=${sendQueue.size}"
3690
4392
  )
3691
4393
  }
3692
4394
  }
@@ -4000,6 +4702,9 @@ class MentraLive : SGCManager() {
4000
4702
  (fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4001
4703
  DeviceStore.apply("glasses", "bluetoothMacAddress", it)
4002
4704
  }
4705
+ (fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4706
+ DeviceStore.apply("glasses", "wifiMacAddress", it)
4707
+ }
4003
4708
  (fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4004
4709
  DeviceStore.apply("glasses", "serialNumber", it)
4005
4710
  }
@@ -4009,6 +4714,12 @@ class MentraLive : SGCManager() {
4009
4714
  DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
4010
4715
  }
4011
4716
  }
4717
+ if (fields.containsKey("hotspot_ota_version")) {
4718
+ val version = fields["hotspot_ota_version"]
4719
+ if (version is Number) {
4720
+ DeviceStore.apply("glasses", "hotspotOtaVersion", version.toInt())
4721
+ }
4722
+ }
4012
4723
 
4013
4724
  // Negotiate wire caps from ANY version_info chunk, not only the one
4014
4725
  // carrying build_number: the glasses put wire_caps in version_info_3
@@ -4365,6 +5076,10 @@ class MentraLive : SGCManager() {
4365
5076
  }
4366
5077
  if (ready == 1) {
4367
5078
  Bridge.log("LIVE: K900 SOC ready")
5079
+ markPairingTiming(
5080
+ "soc_ready",
5081
+ "glassesReady=$glassesReady queueSize=${sendQueue.size}"
5082
+ )
4368
5083
  // Only send phone_ready if we haven't already established connection
4369
5084
  // This prevents re-initialization on every heartbeat after initial
4370
5085
  // connection
@@ -4468,15 +5183,28 @@ class MentraLive : SGCManager() {
4468
5183
  var progress = ((rawProgress + 2) / 5) * 5
4469
5184
  if (progress > 100) progress = 100
4470
5185
 
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
5186
+ val mapping =
5187
+ mapBesOtaProgress(
5188
+ type,
5189
+ rawProgress,
5190
+ bodyObj.optString("message", "BES update failed"),
5191
+ )
5192
+ val besOtaStatus = mapping.status
5193
+ val besOtaProgressVal = mapping.progress
5194
+ val besOtaErrorMessage = mapping.errorMessage
5195
+
5196
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
5197
+ besOtaHeartbeatGuard.clear()
5198
+ } else {
5199
+ // Refresh before progress de-duplication: raw progress proves the BES
5200
+ // transfer is active even when the rounded UI value is unchanged.
5201
+ besOtaHeartbeatGuard.refresh(SystemClock.elapsedRealtime())
5202
+ }
5203
+
5204
+ // Only send if nonterminal display progress changed to a new 5% increment.
5205
+ if ("PROGRESS" == besOtaStatus && besOtaProgressVal == lastBesOtaProgress) {
5206
+ return
4478
5207
  }
4479
- lastBesOtaProgress = progress
4480
5208
 
4481
5209
  Bridge.log(
4482
5210
  "LIVE: 📱 BES OTA progress via sr_adota - type: " +
@@ -4485,58 +5213,21 @@ class MentraLive : SGCManager() {
4485
5213
  rawProgress +
4486
5214
  "%, rounded: " +
4487
5215
  progress +
5216
+ "%, display: " +
5217
+ besOtaProgressVal +
4488
5218
  "%"
4489
5219
  )
4490
5220
 
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
4505
- 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")
5221
+ if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
4510
5222
  lastBesOtaProgress = -1 // Reset for next OTA
4511
- } else if ("update" == type) {
4512
- besOtaStatus = "PROGRESS"
4513
- besOtaProgressVal = progress
4514
5223
  } else {
4515
- // Unknown type, treat as progress
4516
- besOtaStatus = "PROGRESS"
4517
- besOtaProgressVal = progress
5224
+ lastBesOtaProgress = besOtaProgressVal
4518
5225
  }
4519
5226
 
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"
5227
+ val syntheticStatus = when (besOtaStatus) {
5228
+ "FINISHED" -> "step_complete"
5229
+ "FAILED" -> "failed"
5230
+ else -> "in_progress"
4540
5231
  }
4541
5232
  val sid = if (cachedOtaSessionId != null) cachedOtaSessionId!! else ""
4542
5233
  val totalSteps = if (cachedOtaTotalSteps > 0) cachedOtaTotalSteps else 1
@@ -5001,6 +5692,10 @@ class MentraLive : SGCManager() {
5001
5692
  Bridge.log("LIVE: Skipping heartbeat - glasses not ready or not connected")
5002
5693
  return
5003
5694
  }
5695
+ if (besOtaHeartbeatGuard.shouldSuppress(SystemClock.elapsedRealtime())) {
5696
+ Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
5697
+ return
5698
+ }
5004
5699
 
5005
5700
  try {
5006
5701
  // Send ping message (no ACK needed for heartbeats)
@@ -5092,6 +5787,8 @@ class MentraLive : SGCManager() {
5092
5787
 
5093
5788
  /** Start the micbeat mechanism - periodically enable custom audio TX */
5094
5789
  private fun startMicBeat() {
5790
+ // A resumed custom-audio stream starts a new sequence domain.
5791
+ DeviceManager.getInstance().resetMicSequenceBaseline()
5095
5792
  micOnCount++
5096
5793
  Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
5097
5794
  micBeatCount = 0
@@ -5233,8 +5930,16 @@ class MentraLive : SGCManager() {
5233
5930
  override fun findCompatibleDevices() {
5234
5931
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5235
5932
 
5236
- // Clear reconnection mode when user manually scans
5933
+ // A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
5934
+ // and replace any active fast reconnect scan with discovery mode.
5935
+ reconnectGeneration++
5936
+ if (isScanning) {
5937
+ stopScan()
5938
+ }
5939
+ manualDiscoveryActive = true
5237
5940
  isReconnecting = false
5941
+ // Discovery-only: do not treat leftover savedDeviceName as a connect target.
5942
+ explicitConnectByName = false
5238
5943
 
5239
5944
  if (bluetoothAdapter == null) {
5240
5945
  Log.e(TAG, "Bluetooth not available")
@@ -5246,50 +5951,124 @@ class MentraLive : SGCManager() {
5246
5951
  return
5247
5952
  }
5248
5953
 
5954
+ // Stale Classic ACL keeps Mentra Live from advertising — drop A2DP/HFP (keep bond)
5955
+ // so a pairing/discovery scan can see the unit again. Keep the active session's
5956
+ // audio; tear down other bonded Lives so a second unit can advertise.
5957
+ releaseStaleClassicForDiscovery()
5958
+
5249
5959
  // Start scanning for BLE devices
5250
5960
  startScan()
5961
+ handler.post { emitConnectedPendingDeviceForPairingScan() }
5962
+ }
5963
+
5964
+ private fun releaseStaleClassicForDiscovery() {
5965
+ val adapter = bluetoothAdapter ?: return
5966
+ val bonded =
5967
+ try {
5968
+ adapter.bondedDevices?.toList().orEmpty()
5969
+ } catch (e: Exception) {
5970
+ Bridge.log("LIVE: Classic: discovery release — cannot list bonded: " + e.message)
5971
+ return
5972
+ }
5973
+ val mentraBonded =
5974
+ bonded.filter { isMentraLiveBluetoothName(safeDeviceName(it)) }
5975
+ if (mentraBonded.isEmpty()) {
5976
+ return
5977
+ }
5978
+ val currentAddress = connectedDevice?.address
5979
+ for (device in mentraBonded) {
5980
+ if (currentAddress != null && device.address.equals(currentAddress, ignoreCase = true)) {
5981
+ continue
5982
+ }
5983
+ Bridge.log(
5984
+ "LIVE: Classic: releasing stale A2DP/HFP before discovery for " +
5985
+ safeDeviceName(device) +
5986
+ " (" +
5987
+ device.address +
5988
+ ")"
5989
+ )
5990
+ disconnectClassicProfiles(device)
5991
+ }
5251
5992
  }
5252
5993
 
5253
5994
  override fun connectById(id: String) {
5995
+ if (pairingYieldActive) {
5996
+ Bridge.log("LIVE: connectById blocked — pairing yield active")
5997
+ return
5998
+ }
5999
+ reconnectGeneration++
6000
+ manualDiscoveryActive = false
6001
+ if (isScanning) {
6002
+ stopScan()
6003
+ }
6004
+ isReconnecting = false
5254
6005
  Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
5255
6006
  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());
6007
+ // Without this, connectToSmartGlasses() falls back to a name scan with
6008
+ // isReconnecting=false and never GATT-connects (loading screen hangs).
6009
+ explicitConnectByName = true
5263
6010
  connectToSmartGlasses()
5264
6011
  }
5265
6012
 
6013
+ /**
6014
+ * True while Android is showing (or about to show) the system pairing dialog for
6015
+ * CTKD Classic bonding. Calling [removeBond] / tearing down the SGC here aborts that
6016
+ * dialog and forces a full re-pair.
6017
+ */
6018
+ fun isCtkdBondingInProgress(): Boolean {
6019
+ val device = connectedDevice ?: return false
6020
+ return device.bondState == BluetoothDevice.BOND_BONDING
6021
+ }
6022
+
5266
6023
  override fun forget() {
5267
6024
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
5268
6025
 
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");
6026
+ // Clear saved device name so a leftover name can't bypass the pairing-mode
6027
+ // discovery filter for this unit on a subsequent rescan (parity with iOS forget()).
6028
+ savedDeviceName = ""
5273
6029
 
5274
6030
  // Reset reconnection state
5275
6031
  reconnectAttempts = 0
5276
6032
  isReconnecting = false
6033
+ manualDiscoveryActive = false
6034
+ explicitConnectByName = false
5277
6035
 
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
-
6036
+ val ctx = context ?: Bridge.getContext()
6037
+ val bondedAddress = connectedDevice?.address
5285
6038
  if (isScanning) {
5286
6039
  stopScan()
5287
6040
  emitStopScanEvent()
5288
6041
  }
5289
- disconnect()
6042
+ // Tell glasses to drop Mentra owner before tearing the link. removeBond
6043
+ // alone leaves glasses NV owned, so the next pair hits OWNER_ONLY.
6044
+ try {
6045
+ val unpair = JSONObject()
6046
+ unpair.put("type", "unpair")
6047
+ sendJsonWithoutAck(unpair, true)
6048
+ } catch (e: JSONException) {
6049
+ Log.e(TAG, "Error creating unpair command", e)
6050
+ }
6051
+ // Give the unpair write a beat to leave GATT before destroy() cancels the queue.
6052
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
6053
+ unpairFlushPending = true
6054
+ val flush =
6055
+ Runnable {
6056
+ unpairFlushPending = false
6057
+ unpairFlushRunnable = null
6058
+ // Drop GATT first. removeBond while the BLE link is still encrypted often
6059
+ // returns true on Samsung but the OS Bluetooth UI keeps the dual-mode pair.
6060
+ disconnect()
6061
+ unbondBondedMentraLiveDevices(ctx, bondedAddress)
6062
+ }
6063
+ unpairFlushRunnable = flush
6064
+ handler.postDelayed(flush, UNPAIR_FLUSH_MS)
5290
6065
  }
5291
6066
 
5292
6067
  override fun disconnect() {
6068
+ if (unpairFlushPending) {
6069
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6070
+ return
6071
+ }
5293
6072
  Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
5294
6073
  destroy()
5295
6074
  }
@@ -5327,6 +6106,10 @@ class MentraLive : SGCManager() {
5327
6106
  }
5328
6107
 
5329
6108
  fun connectToSmartGlasses() {
6109
+ if (pairingYieldActive) {
6110
+ Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6111
+ return
6112
+ }
5330
6113
  Bridge.log("LIVE: Connecting to Mentra Live glasses")
5331
6114
  updateConnectionState(ConnTypes.CONNECTING)
5332
6115
 
@@ -5355,7 +6138,13 @@ class MentraLive : SGCManager() {
5355
6138
  // var context = Bridge.getContext();
5356
6139
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5357
6140
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
5358
- val lastDeviceAddress = DeviceStore.get("bluetooth", "device_address") as String?
6141
+ val pendingAddress =
6142
+ (DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
6143
+ it.isNotEmpty()
6144
+ }
6145
+ val lastDeviceAddress =
6146
+ pendingAddress
6147
+ ?: (DeviceStore.get("bluetooth", "device_address") as String?)
5359
6148
 
5360
6149
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
5361
6150
  // Connect to last known device if available
@@ -5366,6 +6155,7 @@ class MentraLive : SGCManager() {
5366
6155
  Bridge.log(
5367
6156
  "LIVE: Found saved device, connecting directly: " + lastDeviceAddress
5368
6157
  )
6158
+ prepareClassicBeforeBleConnect(device)
5369
6159
  connectToDevice(device)
5370
6160
  } else {
5371
6161
  Bridge.log(
@@ -5381,12 +6171,62 @@ class MentraLive : SGCManager() {
5381
6171
  startScan() // Fallback to scanning
5382
6172
  }
5383
6173
  } else {
5384
- // If no last known device, start scanning for devices
5385
- Bridge.log("LIVE: No last known device, starting scan")
5386
- startScan()
6174
+ // Pairing may only have a name (or address wasn't stashed). Prefer a bonded
6175
+ // device match over scan Classic-connected glasses often stop advertising.
6176
+ val bonded = findBondedDeviceByName(savedDeviceName)
6177
+ if (bonded != null) {
6178
+ Bridge.log(
6179
+ "LIVE: No saved address — using bonded device " +
6180
+ safeDeviceName(bonded) +
6181
+ " (" +
6182
+ bonded.address +
6183
+ ")"
6184
+ )
6185
+ prepareClassicBeforeBleConnect(bonded)
6186
+ connectToDevice(bonded)
6187
+ } else {
6188
+ Bridge.log("LIVE: No last known device, starting scan")
6189
+ startScan()
6190
+ }
6191
+ }
6192
+ }
6193
+
6194
+ private fun findBondedDeviceByName(name: String): BluetoothDevice? {
6195
+ if (name.isEmpty() || bluetoothAdapter == null) {
6196
+ return null
6197
+ }
6198
+ return try {
6199
+ bluetoothAdapter!!.bondedDevices?.firstOrNull { safeDeviceName(it) == name }
6200
+ } catch (e: SecurityException) {
6201
+ Bridge.log("LIVE: Unable to read bonded devices: " + e.message)
6202
+ null
6203
+ } catch (e: Exception) {
6204
+ Bridge.log("LIVE: Error reading bonded devices: " + e.message)
6205
+ null
5387
6206
  }
5388
6207
  }
5389
6208
 
6209
+ /**
6210
+ * Fresh pairing / discovery may need Classic down so glasses leave a stuck ACL.
6211
+ * Owner reconnect must NOT tear Classic first: BES BLE-PAIR-GUARD historically
6212
+ * admitted Mentra App RPAs only while Classic owner ACL was up — tearing it
6213
+ * caused GATT status=19 reject loops after Mentra App disconnect.
6214
+ */
6215
+ private fun prepareClassicBeforeBleConnect(device: BluetoothDevice) {
6216
+ if (device.bondState != BluetoothDevice.BOND_BONDED) {
6217
+ return
6218
+ }
6219
+ // Owner reconnect and connectById both go through connectToSmartGlasses(),
6220
+ // which clears isReconnecting first. Leave Classic up: tearing A2DP/HFP
6221
+ // here historically caused GATT status=19 reject loops. Discovery teardown
6222
+ // lives in releaseStaleClassicForDiscovery() for pairing scans only.
6223
+ Bridge.log(
6224
+ "LIVE: Classic: bonded target — leave A2DP/HFP up for BLE admit (" +
6225
+ safeDeviceName(device) +
6226
+ ")"
6227
+ )
6228
+ }
6229
+
5390
6230
  override fun setMicEnabled(enabled: Boolean) {
5391
6231
  Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
5392
6232
 
@@ -5830,41 +6670,67 @@ class MentraLive : SGCManager() {
5830
6670
  Bridge.log(
5831
6671
  "LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
5832
6672
  )
6673
+ markPairingTiming(
6674
+ "ctkd_bonded",
6675
+ "glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
6676
+ )
5833
6677
  if (isKilled) {
5834
6678
  Bridge.log(
5835
6679
  "LIVE: CTKD: Ignoring bond complete — SGC destroyed"
5836
6680
  )
5837
6681
  return@run
5838
6682
  }
5839
- isBtClassicConnected = true
5840
- audioConnected = true
5841
6683
  bondingRetryCount =
5842
6684
  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
6685
+ // Bond != audio profile. Explicitly open A2DP
6686
+ // after createBond() completes (already-bonded
6687
+ // path does this at GATT connect).
6688
+ // markAudioConnected / sendAudioConnected fire
6689
+ // when the A2DP path completes.
6690
+ connectA2dpProfile(device)
6691
+
6692
+ // Safety net: if glasses_ready somehow missed
6693
+ // setting fullyBooted, catch up here.
5848
6694
  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)
6695
+ val alreadyBooted =
6696
+ DeviceStore.store.get(
6697
+ "glasses",
6698
+ "fullyBooted"
6699
+ ) as? Boolean
6700
+ ?: false
6701
+ if (!alreadyBooted) {
6702
+ Bridge.log(
6703
+ "LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
6704
+ )
6705
+ endPairingTiming(
6706
+ "fully_connected",
6707
+ "via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
6708
+ )
6709
+ DeviceStore.apply(
6710
+ "glasses",
6711
+ "fullyBooted",
6712
+ true
6713
+ )
6714
+ updateConnectionState(ConnTypes.CONNECTED)
6715
+ } else {
6716
+ Bridge.log(
6717
+ "LIVE: Audio: CTKD bonded after pairing UI already advanced"
6718
+ )
6719
+ markPairingTiming(
6720
+ "ctkd_bonded_after_fully_connected",
6721
+ "queueSize=${sendQueue.size}"
6722
+ )
6723
+ }
5858
6724
  }
5859
-
5860
- // Send audio connected event for platform parity
5861
- // with iOS
5862
- Bridge.sendAudioConnected(device.name)
5863
6725
  }
5864
6726
  BluetoothDevice.BOND_NONE -> {
5865
6727
  Bridge.log(
5866
6728
  "LIVE: CTKD: ❌ Bonding failed or removed for device"
5867
6729
  )
6730
+ markPairingTiming(
6731
+ "ctkd_bond_none",
6732
+ "prev=$previousBondState queueSize=${sendQueue.size}"
6733
+ )
5868
6734
  isBtClassicConnected = false
5869
6735
  audioConnected = false
5870
6736
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
@@ -5925,10 +6791,12 @@ class MentraLive : SGCManager() {
5925
6791
  Bridge.sendAudioDisconnected()
5926
6792
  }
5927
6793
  }
5928
- BluetoothDevice.BOND_BONDING ->
6794
+ BluetoothDevice.BOND_BONDING -> {
5929
6795
  Bridge.log(
5930
6796
  "LIVE: CTKD: 🔄 Bonding in progress with device"
5931
6797
  )
6798
+ markPairingTiming("ctkd_bonding")
6799
+ }
5932
6800
  else ->
5933
6801
  Bridge.log(
5934
6802
  "LIVE: CTKD: Unknown bond state: " + bondState
@@ -5963,6 +6831,48 @@ class MentraLive : SGCManager() {
5963
6831
  }
5964
6832
  }
5965
6833
 
6834
+ /**
6835
+ * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
6836
+ * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
6837
+ * (peer terminate) on reconnect when A2DP is already up.
6838
+ */
6839
+ private fun maybeInitiateCtkdAudioAfterBleReady() {
6840
+ if (isKilled) {
6841
+ return
6842
+ }
6843
+ val device = connectedDevice
6844
+ if (device == null) {
6845
+ Bridge.log("LIVE: CTKD: Skipping initiate — no connected device")
6846
+ return
6847
+ }
6848
+ if (ctkdInitiatedThisGattSession) {
6849
+ return
6850
+ }
6851
+ ctkdInitiatedThisGattSession = true
6852
+ Bridge.log(
6853
+ "LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
6854
+ device.bondState +
6855
+ ")"
6856
+ )
6857
+ markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
6858
+ when (device.bondState) {
6859
+ BluetoothDevice.BOND_BONDED -> {
6860
+ Bridge.log(
6861
+ "LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
6862
+ )
6863
+ markPairingTiming("ctkd_already_bonded")
6864
+ connectA2dpProfile(device)
6865
+ }
6866
+ BluetoothDevice.BOND_BONDING -> {
6867
+ // Secure pairing firmware may request SMP as soon as GATT connects.
6868
+ // Let that in-flight procedure finish instead of calling createBond twice.
6869
+ Bridge.log("LIVE: CTKD: Bonding already in progress - waiting for completion")
6870
+ markPairingTiming("ctkd_bonding_already_in_progress")
6871
+ }
6872
+ else -> createBond(device)
6873
+ }
6874
+ }
6875
+
5966
6876
  /**
5967
6877
  * Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
5968
6878
  * BLE and BT Classic connections automatically
@@ -6069,6 +6979,7 @@ class MentraLive : SGCManager() {
6069
6979
 
6070
6980
  if (state == BluetoothProfile.STATE_CONNECTED) {
6071
6981
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
6982
+ a2dpConnectAttempts = 0
6072
6983
  markAudioConnected(device.name)
6073
6984
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
6074
6985
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
@@ -6077,37 +6988,50 @@ class MentraLive : SGCManager() {
6077
6988
  BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
6078
6989
  val result = connectMethod.invoke(a2dpProfile, device) as Boolean
6079
6990
  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)
6991
+ if (result) {
6992
+ scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
6993
+ } else {
6994
+ scheduleA2dpConnectRetry(device, "connect() returned false")
6995
+ }
6086
6996
  } else if (state == BluetoothProfile.STATE_CONNECTING) {
6087
- Bridge.log("LIVE: A2DP: Already connecting, marking audio as connected")
6088
- markAudioConnected(device.name)
6997
+ Bridge.log("LIVE: A2DP: Already connecting waiting for STATE_CONNECTED")
6998
+ scheduleA2dpConnectRetry(device, "still connecting")
6089
6999
  } 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
- )
7000
+ scheduleA2dpConnectRetry(device, "device disconnecting")
6103
7001
  }
6104
7002
  } catch (e: Exception) {
6105
7003
  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)
7004
+ scheduleA2dpConnectRetry(device, "exception: ${e.message}")
6108
7005
  }
6109
7006
  }
6110
7007
 
7008
+ private fun scheduleA2dpConnectRetry(device: BluetoothDevice, reason: String) {
7009
+ a2dpConnectAttempts++
7010
+ if (a2dpConnectAttempts > A2DP_CONNECT_MAX_ATTEMPTS) {
7011
+ Bridge.log(
7012
+ "LIVE: A2DP: Giving up after $A2DP_CONNECT_MAX_ATTEMPTS attempts ($reason). " +
7013
+ "Classic bond may exist, but A2DP/HFP is not up. BLE/LC3 still works."
7014
+ )
7015
+ return
7016
+ }
7017
+ Bridge.log(
7018
+ "LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
7019
+ "${A2DP_CONNECT_RETRY_MS}ms ($reason)"
7020
+ )
7021
+ handler.postDelayed(
7022
+ Runnable {
7023
+ if (isKilled || pairingYieldActive || connectedDevice == null) {
7024
+ return@Runnable
7025
+ }
7026
+ val target = connectedDevice ?: return@Runnable
7027
+ if (a2dpProfile != null) {
7028
+ connectA2dpWithProxy(target)
7029
+ }
7030
+ },
7031
+ A2DP_CONNECT_RETRY_MS
7032
+ )
7033
+ }
7034
+
6111
7035
  /** Helper to mark audio as connected and notify */
6112
7036
  private fun markAudioConnected(deviceName: String?) {
6113
7037
  if (isKilled) {
@@ -6123,8 +7047,14 @@ class MentraLive : SGCManager() {
6123
7047
  Bridge.log(
6124
7048
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
6125
7049
  )
7050
+ endPairingTiming(
7051
+ "fully_connected",
7052
+ "via=a2dp+glasses_ready queueSize=${sendQueue.size}"
7053
+ )
6126
7054
  DeviceStore.apply("glasses", "fullyBooted", true)
6127
7055
  updateConnectionState(ConnTypes.CONNECTED)
7056
+ } else {
7057
+ markPairingTiming("a2dp_connected_waiting_glasses_ready")
6128
7058
  }
6129
7059
  }
6130
7060
 
@@ -6148,6 +7078,7 @@ class MentraLive : SGCManager() {
6148
7078
  }
6149
7079
 
6150
7080
  Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
7081
+ a2dpConnectAttempts = 0
6151
7082
 
6152
7083
  // If we already have the proxy, try to connect directly
6153
7084
  if (a2dpProfile != null) {
@@ -6168,16 +7099,133 @@ class MentraLive : SGCManager() {
6168
7099
  Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
6169
7100
  } else {
6170
7101
  Bridge.log(
6171
- "LIVE: A2DP: Failed to get profile proxy, marking audio connected anyway"
7102
+ "LIVE: A2DP: Failed to get profile proxy Classic audio not connected"
6172
7103
  )
6173
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6174
- markAudioConnected(device.name)
6175
7104
  }
6176
7105
  } else {
6177
7106
  Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
6178
7107
  }
6179
7108
  }
6180
7109
 
7110
+ /**
7111
+ * Drop Classic audio profiles (A2DP + HFP). App disconnect previously only closed BLE GATT /
7112
+ * the A2DP proxy, so Android Settings still showed Mentra Live as connected and the next BLE
7113
+ * reconnect fought a live Classic link (GATT 19). Bond is intentionally kept for reconnect —
7114
+ * only [forget] calls removeBond.
7115
+ */
7116
+ private fun disconnectClassicProfiles(device: BluetoothDevice?) {
7117
+ if (device == null || bluetoothAdapter == null || context == null) {
7118
+ Bridge.log("LIVE: Classic: skip profile teardown — missing device/adapter/context")
7119
+ return
7120
+ }
7121
+ Bridge.log(
7122
+ "LIVE: Classic: tearing down A2DP/HFP for " +
7123
+ safeDeviceName(device) +
7124
+ " (" +
7125
+ device.address +
7126
+ ")"
7127
+ )
7128
+ val isActiveDevice =
7129
+ connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7130
+ if (isActiveDevice) {
7131
+ isBtClassicConnected = false
7132
+ audioConnected = false
7133
+ }
7134
+
7135
+ if (a2dpProfile != null) {
7136
+ invokeHiddenProfileDisconnect(
7137
+ a2dpProfile!!,
7138
+ device,
7139
+ BluetoothA2dp::class.java,
7140
+ "A2DP"
7141
+ )
7142
+ } else {
7143
+ requestProfileDisconnect(BluetoothProfile.A2DP, device, BluetoothA2dp::class.java, "A2DP")
7144
+ }
7145
+ // HFP/SCO often stays up after A2DP-only teardown (shows as "Bluetooth Headset" in routes).
7146
+ requestProfileDisconnect(
7147
+ BluetoothProfile.HEADSET,
7148
+ device,
7149
+ BluetoothHeadset::class.java,
7150
+ "HFP"
7151
+ )
7152
+ }
7153
+
7154
+ private fun invokeHiddenProfileDisconnect(
7155
+ proxy: BluetoothProfile,
7156
+ device: BluetoothDevice,
7157
+ proxyClass: Class<*>,
7158
+ label: String
7159
+ ) {
7160
+ try {
7161
+ val state = proxy.getConnectionState(device)
7162
+ Bridge.log("LIVE: Classic: " + label + " state=" + state)
7163
+ if (state == BluetoothProfile.STATE_CONNECTED ||
7164
+ state == BluetoothProfile.STATE_CONNECTING
7165
+ ) {
7166
+ val disconnectMethod =
7167
+ proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
7168
+ val result = disconnectMethod.invoke(proxy, device) as Boolean
7169
+ Bridge.log("LIVE: Classic: " + label + " disconnect result=" + result)
7170
+ } else {
7171
+ Bridge.log("LIVE: Classic: " + label + " already idle — nothing to disconnect")
7172
+ }
7173
+ } catch (e: Exception) {
7174
+ Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
7175
+ }
7176
+ }
7177
+
7178
+ private fun requestProfileDisconnect(
7179
+ profile: Int,
7180
+ device: BluetoothDevice,
7181
+ proxyClass: Class<*>,
7182
+ label: String
7183
+ ) {
7184
+ val adapter = bluetoothAdapter ?: return
7185
+ val ctx = context ?: return
7186
+ try {
7187
+ val ok =
7188
+ adapter.getProfileProxy(
7189
+ ctx,
7190
+ object : BluetoothProfile.ServiceListener {
7191
+ override fun onServiceConnected(
7192
+ profileType: Int,
7193
+ proxy: BluetoothProfile
7194
+ ) {
7195
+ if (profileType != profile) {
7196
+ return
7197
+ }
7198
+ try {
7199
+ invokeHiddenProfileDisconnect(
7200
+ proxy,
7201
+ device,
7202
+ proxyClass,
7203
+ label
7204
+ )
7205
+ } finally {
7206
+ try {
7207
+ adapter.closeProfileProxy(profile, proxy)
7208
+ } catch (e: Exception) {
7209
+ Bridge.log(
7210
+ "LIVE: Classic: error closing " +
7211
+ label +
7212
+ " proxy: " +
7213
+ e.message
7214
+ )
7215
+ }
7216
+ }
7217
+ }
7218
+
7219
+ override fun onServiceDisconnected(profileType: Int) {}
7220
+ },
7221
+ profile
7222
+ )
7223
+ Bridge.log("LIVE: Classic: requested " + label + " proxy for disconnect, ok=" + ok)
7224
+ } catch (e: Exception) {
7225
+ Bridge.log("LIVE: Classic: failed to request " + label + " proxy: " + e.message)
7226
+ }
7227
+ }
7228
+
6181
7229
  /** Close the A2DP profile proxy */
6182
7230
  private fun closeA2dpProxy() {
6183
7231
  if (a2dpProfile != null && bluetoothAdapter != null) {
@@ -6193,6 +7241,14 @@ class MentraLive : SGCManager() {
6193
7241
 
6194
7242
  // Mark as killed to prevent reconnection attempts
6195
7243
  isKilled = true
7244
+ explicitConnectByName = false
7245
+ pairingYieldActive = false
7246
+ pairingYieldAwaitingReclaim = false
7247
+ pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
7248
+ pairingYieldEndRunnable = null
7249
+ unpairFlushRunnable?.let { handler.removeCallbacks(it) }
7250
+ unpairFlushRunnable = null
7251
+ unpairFlushPending = false
6196
7252
 
6197
7253
  // Stop scanning if in progress
6198
7254
  if (isScanning) {
@@ -6203,7 +7259,10 @@ class MentraLive : SGCManager() {
6203
7259
  // CTKD Implementation: Unregister bonding receiver
6204
7260
  unregisterBondingReceiver()
6205
7261
 
6206
- // Close A2DP profile proxy
7262
+ // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7263
+ // phone's system Bluetooth UI showing Mentra Live still connected.
7264
+ val classicDevice = connectedDevice
7265
+ disconnectClassicProfiles(classicDevice)
6207
7266
  closeA2dpProxy()
6208
7267
 
6209
7268
  // Stop readiness check loop
@@ -6274,6 +7333,9 @@ class MentraLive : SGCManager() {
6274
7333
  reconnectAttempts = 0
6275
7334
  isReconnecting = false
6276
7335
  glassesReady = false
7336
+ glassesReadyReceived = false
7337
+ audioConnected = false
7338
+ ctkdInitiatedThisGattSession = false
6277
7339
  DeviceStore.apply("glasses", "fullyBooted", false)
6278
7340
  updateConnectionState(ConnTypes.DISCONNECTED)
6279
7341
 
@@ -7293,6 +8355,7 @@ class MentraLive : SGCManager() {
7293
8355
  /** Sends phone_ready to (re)start the glasses-driven readiness flow. */
7294
8356
  private fun sendPhoneReady(reason: String) {
7295
8357
  Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
8358
+ markPairingTiming("phone_ready_sent", "reason=$reason queueSize=${sendQueue.size}")
7296
8359
  val readyMsg = JSONObject()
7297
8360
  readyMsg.put("type", "phone_ready")
7298
8361
  readyMsg.put("timestamp", System.currentTimeMillis())
@@ -7416,6 +8479,10 @@ class MentraLive : SGCManager() {
7416
8479
  payload
7417
8480
  )
7418
8481
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
8482
+ markPairingTiming(
8483
+ "wire_handshake_sent",
8484
+ "attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
8485
+ )
7419
8486
  wireHandshakeQueued = true
7420
8487
  wireHandshakeSentGeneration = wireSessionGeneration
7421
8488
  queueData(packed, null)
@@ -7442,6 +8509,10 @@ class MentraLive : SGCManager() {
7442
8509
  WIRE_HANDSHAKE_MAX_ATTEMPTS +
7443
8510
  ") - re-arming"
7444
8511
  )
8512
+ markPairingTiming(
8513
+ "wire_handshake_unanswered",
8514
+ "attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
8515
+ )
7445
8516
  wireHandshakeQueued = false
7446
8517
  if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7447
8518
  maybeSendWireHandshake()
@@ -7470,6 +8541,7 @@ class MentraLive : SGCManager() {
7470
8541
  Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7471
8542
  return
7472
8543
  }
8544
+ markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
7473
8545
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7474
8546
  }
7475
8547
 
@@ -7570,10 +8642,22 @@ class MentraLive : SGCManager() {
7570
8642
  * @param data The string data to be sent to the glasses
7571
8643
  */
7572
8644
  fun sendDataToGlasses(data: String?, wakeup: Boolean) {
8645
+ sendDataToGlasses(data, wakeup, bleSessionGeneration.get())
8646
+ }
8647
+
8648
+ private fun sendDataToGlasses(
8649
+ data: String?,
8650
+ wakeup: Boolean,
8651
+ sessionGeneration: Long
8652
+ ) {
7573
8653
  if (data == null || data.isEmpty()) {
7574
8654
  Log.e(TAG, "Cannot send empty data to glasses")
7575
8655
  return
7576
8656
  }
8657
+ if (sessionGeneration != bleSessionGeneration.get()) {
8658
+ Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
8659
+ return
8660
+ }
7577
8661
 
7578
8662
  val wireData =
7579
8663
  try {
@@ -7600,7 +8684,13 @@ class MentraLive : SGCManager() {
7600
8684
  }
7601
8685
 
7602
8686
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
7603
- sendDataToGlassesBinary(wireData, wakeup, commandTraceInfo, isPhotoRequest)
8687
+ sendDataToGlassesBinary(
8688
+ wireData,
8689
+ wakeup,
8690
+ commandTraceInfo,
8691
+ isPhotoRequest,
8692
+ sessionGeneration
8693
+ )
7604
8694
  return
7605
8695
  }
7606
8696
 
@@ -7679,7 +8769,7 @@ class MentraLive : SGCManager() {
7679
8769
  )
7680
8770
 
7681
8771
  // Queue the chunk for sending
7682
- queueData(packedData, trace)
8772
+ queueData(packedData, trace, sessionGeneration)
7683
8773
 
7684
8774
  // Add small delay between chunks to avoid overwhelming the connection
7685
8775
  if (i < chunks.size - 1) {
@@ -7720,7 +8810,7 @@ class MentraLive : SGCManager() {
7720
8810
  )
7721
8811
 
7722
8812
  // Queue the data for sending
7723
- queueData(packedData, trace)
8813
+ queueData(packedData, trace, sessionGeneration)
7724
8814
  if (isPhotoRequest) {
7725
8815
  Bridge.log(
7726
8816
  "LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
@@ -7738,7 +8828,8 @@ class MentraLive : SGCManager() {
7738
8828
  data: String,
7739
8829
  wakeup: Boolean,
7740
8830
  commandTraceInfo: OutgoingBleCommandTraceInfo,
7741
- isPhotoRequest: Boolean
8831
+ isPhotoRequest: Boolean,
8832
+ sessionGeneration: Long
7742
8833
  ) {
7743
8834
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
7744
8835
  var messageId = 0
@@ -7790,7 +8881,7 @@ class MentraLive : SGCManager() {
7790
8881
  "wireProtocol" to "v2"
7791
8882
  )
7792
8883
  )
7793
- queueData(packedData, trace)
8884
+ queueData(packedData, trace, sessionGeneration)
7794
8885
 
7795
8886
  if (i < fragments.size - 1) {
7796
8887
  try {
@@ -8124,6 +9215,19 @@ class MentraLive : SGCManager() {
8124
9215
  }
8125
9216
  }
8126
9217
 
9218
+ override fun sendWifiAdbState(enabled: Boolean) {
9219
+ Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state to glasses - enabled: " + enabled)
9220
+ try {
9221
+ val command = JSONObject()
9222
+ command.put("type", "set_wifi_adb_state")
9223
+ command.put("enabled", enabled)
9224
+ sendJson(command, true)
9225
+ Bridge.log("LIVE: 🔧 ✅ Wi-Fi ADB state command sent successfully")
9226
+ } catch (e: JSONException) {
9227
+ Log.e(TAG, "🔧 💥 Error creating Wi-Fi ADB state JSON", e)
9228
+ }
9229
+ }
9230
+
8127
9231
  override fun sendSetSystemTime(timestampMs: Long) {
8128
9232
  Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
8129
9233
  try {
@@ -9311,6 +10415,21 @@ class MentraLive : SGCManager() {
9311
10415
  stopVideoRecording(requestId, null, null)
9312
10416
  }
9313
10417
 
10418
+ override fun queryVideoRecordingStatus(requestId: String) {
10419
+ if (!isConnected) {
10420
+ Log.w(TAG, "Cannot query video recording status - not connected")
10421
+ return
10422
+ }
10423
+ try {
10424
+ val json = JSONObject()
10425
+ json.put("type", "get_video_recording_status")
10426
+ json.put("requestId", requestId)
10427
+ sendJson(json, true)
10428
+ } catch (e: JSONException) {
10429
+ Log.e(TAG, "Failed to create video recording status query", e)
10430
+ }
10431
+ }
10432
+
9314
10433
  override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
9315
10434
  Bridge.log(
9316
10435
  "LIVE: Stopping video recording: requestId=" +
@@ -9361,20 +10480,6 @@ class MentraLive : SGCManager() {
9361
10480
  val sequenceNumber = data[1]
9362
10481
  val receiveTime = System.currentTimeMillis()
9363
10482
 
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
10483
  val lc3Data = Arrays.copyOfRange(data, 2, data.size)
9379
10484
 
9380
10485
  // Enhanced LC3 packet logging and saving
@@ -9386,7 +10491,11 @@ class MentraLive : SGCManager() {
9386
10491
 
9387
10492
  // Forward raw LC3 to DeviceManager (matches iOS behavior)
9388
10493
  // MentraLive uses 40-byte LC3 frames
9389
- DeviceManager.getInstance().handleGlassesMicData(lc3Data, LC3_FRAME_SIZE)
10494
+ DeviceManager.getInstance().handleGlassesMicData(
10495
+ lc3Data,
10496
+ LC3_FRAME_SIZE,
10497
+ sequenceNumber.toInt() and 0xff,
10498
+ )
9390
10499
 
9391
10500
  // Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
9392
10501
  // } else {