@mentra/bluetooth-sdk 0.1.21-beta.5 → 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 (140) 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 +205 -19
  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/MentraLive.kt +1186 -191
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  38. package/build/BluetoothSdk.types.d.ts +82 -2
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_internal.d.ts +2 -0
  42. package/build/_internal.d.ts.map +1 -1
  43. package/build/_internal.js +2 -0
  44. package/build/_internal.js.map +1 -1
  45. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  46. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  47. package/build/_private/BluetoothSdkModule.js +1 -1
  48. package/build/_private/BluetoothSdkModule.js.map +1 -1
  49. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  50. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  51. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  52. package/build/_private/sdkOtaManifest.d.ts +3 -12
  53. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  54. package/build/_private/sdkOtaManifest.js +5 -17
  55. package/build/_private/sdkOtaManifest.js.map +1 -1
  56. package/build/changelogs.d.ts +7 -0
  57. package/build/changelogs.d.ts.map +1 -0
  58. package/build/changelogs.js +44 -0
  59. package/build/changelogs.js.map +1 -0
  60. package/build/generated/changelogCatalog.d.ts +6 -0
  61. package/build/generated/changelogCatalog.d.ts.map +1 -0
  62. package/build/generated/changelogCatalog.js +8 -0
  63. package/build/generated/changelogCatalog.js.map +1 -0
  64. package/build/generated/releaseMetadata.d.ts +12 -0
  65. package/build/generated/releaseMetadata.d.ts.map +1 -0
  66. package/build/generated/releaseMetadata.js +10 -0
  67. package/build/generated/releaseMetadata.js.map +1 -0
  68. package/build/index.d.ts +1 -1
  69. package/build/index.d.ts.map +1 -1
  70. package/build/index.js +24 -2
  71. package/build/index.js.map +1 -1
  72. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  73. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  74. package/build/ota-server/MentraOtaServer.types.js +2 -0
  75. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  76. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  77. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  78. package/build/ota-server/MentraOtaServerModule.js +3 -0
  79. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  80. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  81. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  82. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  83. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  84. package/build/ota-server/index.d.ts +3 -0
  85. package/build/ota-server/index.d.ts.map +1 -0
  86. package/build/ota-server/index.js +3 -0
  87. package/build/ota-server/index.js.map +1 -0
  88. package/build/ota-transport/index.d.ts +67 -0
  89. package/build/ota-transport/index.d.ts.map +1 -0
  90. package/build/ota-transport/index.js +58 -0
  91. package/build/ota-transport/index.js.map +1 -0
  92. package/expo-module.config.json +3 -2
  93. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  94. package/ios/BluetoothSdkModule.swift +16 -2
  95. package/ios/LocalIPv4.swift +150 -0
  96. package/ios/LocalOtaServer.swift +309 -0
  97. package/ios/MentraBluetoothSDK.podspec +5 -1
  98. package/ios/MentraOtaServerModule.swift +147 -0
  99. package/ios/MentraPhotoReceiverModule.swift +1 -81
  100. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  101. package/ios/Source/Bridge.swift +43 -4
  102. package/ios/Source/DeviceManager.swift +143 -13
  103. package/ios/Source/DeviceStore.swift +2 -0
  104. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  105. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  106. package/ios/Source/MentraBluetoothSDK.swift +91 -6
  107. package/ios/Source/OtaManifest.swift +5 -7
  108. package/ios/Source/ReleaseChangelog.swift +65 -0
  109. package/ios/Source/audio/AudioModels.swift +17 -0
  110. package/ios/Source/camera/CameraModels.swift +1 -0
  111. package/ios/Source/events/BluetoothEvents.swift +27 -0
  112. package/ios/Source/sgcs/MentraLive.swift +453 -31
  113. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  114. package/ios/Source/sgcs/SGCManager.swift +11 -0
  115. package/ios/Source/status/DeviceStatus.swift +23 -0
  116. package/ios/Source/types/DeviceModels.swift +23 -2
  117. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  118. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  119. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  120. package/package.json +10 -1
  121. package/plugin/build/withIos.d.ts +3 -1
  122. package/plugin/build/withIos.js +22 -10
  123. package/scripts/public-ota-api.test.mjs +37 -0
  124. package/scripts/verify-release-package.mjs +34 -0
  125. package/scripts/write-release-metadata.mjs +154 -0
  126. package/scripts/write-release-metadata.test.mjs +29 -0
  127. package/src/BluetoothSdk.types.ts +95 -3
  128. package/src/_internal.ts +2 -0
  129. package/src/_private/BluetoothSdkModule.ts +5 -5
  130. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  131. package/src/_private/sdkOtaManifest.ts +5 -17
  132. package/src/changelogs.ts +44 -0
  133. package/src/generated/changelogCatalog.ts +9 -0
  134. package/src/generated/releaseMetadata.ts +20 -0
  135. package/src/index.ts +43 -2
  136. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  137. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  138. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  139. package/src/ota-server/index.ts +2 -0
  140. 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
@@ -76,6 +77,38 @@ import org.json.JSONArray
76
77
  import org.json.JSONException
77
78
  import org.json.JSONObject
78
79
 
80
+ internal fun hasRequiredMentraLiveCoreCharacteristics(
81
+ hasRxCharacteristic: Boolean,
82
+ hasTxCharacteristic: Boolean,
83
+ ): Boolean = hasRxCharacteristic && hasTxCharacteristic
84
+
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
+
79
112
  /**
80
113
  * Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
81
114
  * glasses
@@ -83,6 +116,12 @@ import org.json.JSONObject
83
116
  * Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
84
117
  * display-related methods are stubbed out and will log a message but not actually display anything.
85
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
+
86
125
  class MentraLive : SGCManager() {
87
126
  private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
88
127
 
@@ -99,6 +138,9 @@ class MentraLive : SGCManager() {
99
138
  private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
100
139
  private const val LOUDNESS_GATE_SWITCH_TYPE = 10
101
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
102
144
 
103
145
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
104
146
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
@@ -142,6 +184,253 @@ class MentraLive : SGCManager() {
142
184
  10000 // 10 seconds for reconnection scans (faster than 60s default)
143
185
  private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
144
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
+
145
434
  private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
146
435
 
147
436
  // Keep-alive parameters
@@ -253,6 +542,16 @@ class MentraLive : SGCManager() {
253
542
 
254
543
  private var reconnectAttempts = 0
255
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
256
555
  /**
257
556
  * Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
258
557
  * so glasses can reboot.
@@ -276,18 +575,35 @@ class MentraLive : SGCManager() {
276
575
  private val fileProcessingHandler = Handler(fileProcessingThread.looper)
277
576
  private var scheduler: ScheduledExecutorService? = null
278
577
  private var isScanning = false
578
+ // Prevent an old scan timeout from stopping a newer manual scan.
579
+ private var scanGeneration = 0
279
580
  private var isConnecting = false
280
581
  private var isKilled = false
281
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
+
282
592
  // CTKD (Cross-Transport Key Derivation) support for BES devices
283
593
  private var isBondingReceiverRegistered = false
284
594
  private var isBtClassicConnected = false
285
595
  private var bondingReceiver: BroadcastReceiver? = null
286
596
  private var bondingRetryCount = 0
287
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
+
288
603
  // A2DP profile connection for already-bonded devices
289
604
  private var a2dpProfile: BluetoothA2dp? = null
290
605
  private var isA2dpProxyRegistered = false
606
+ private var a2dpConnectAttempts = 0
291
607
 
292
608
  private data class OutgoingBleCommandTraceInfo(
293
609
  val commandType: String,
@@ -609,6 +925,9 @@ class MentraLive : SGCManager() {
609
925
  // Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
610
926
  private var glassesReadyReceived = false
611
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
612
931
 
613
932
  // Micbeat tracking - periodically enable custom audio TX
614
933
  private var micBeatHandler = Handler(Looper.getMainLooper())
@@ -624,7 +943,6 @@ class MentraLive : SGCManager() {
624
943
  private val secureRandom = SecureRandom()
625
944
  private val deviceId = System.currentTimeMillis() xor Random().nextLong()
626
945
 
627
- private var lastReceivedLc3Sequence: Byte = -1
628
946
  private var lc3SequenceNumber: Byte = 0
629
947
  private var lc3DecoderPtr = 0L
630
948
  private var lc3AudioPlayer: Lc3Player? = null
@@ -811,6 +1129,9 @@ class MentraLive : SGCManager() {
811
1129
  }
812
1130
 
813
1131
  private fun updateConnectionState(state: String) {
1132
+ if (state == ConnTypes.DISCONNECTED) {
1133
+ DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
1134
+ }
814
1135
  val isEqual = state == connectionState
815
1136
  if (isEqual) {
816
1137
  if (state == ConnTypes.DISCONNECTED) {
@@ -833,6 +1154,7 @@ class MentraLive : SGCManager() {
833
1154
  // same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
834
1155
  DeviceStore.apply("glasses", "serialNumber", "")
835
1156
  DeviceStore.apply("glasses", "bluetoothMacAddress", "")
1157
+ DeviceStore.apply("glasses", "wifiMacAddress", "")
836
1158
  }
837
1159
 
838
1160
  // Actually update the connection state!
@@ -905,6 +1227,10 @@ class MentraLive : SGCManager() {
905
1227
 
906
1228
  /** Starts BLE scanning for Mentra Live glasses */
907
1229
  private fun startScan() {
1230
+ if (pairingYieldActive) {
1231
+ Bridge.log("LIVE: startScan blocked — pairing yield active")
1232
+ return
1233
+ }
908
1234
  if (bluetoothAdapter == null || isScanning) {
909
1235
  return
910
1236
  }
@@ -963,6 +1289,7 @@ class MentraLive : SGCManager() {
963
1289
  )
964
1290
  }
965
1291
 
1292
+ val generation = ++scanGeneration
966
1293
  isScanning = true
967
1294
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
968
1295
 
@@ -970,6 +1297,9 @@ class MentraLive : SGCManager() {
970
1297
  handler.postDelayed(
971
1298
  object : Runnable {
972
1299
  override fun run() {
1300
+ if (generation != scanGeneration) {
1301
+ return
1302
+ }
973
1303
  if (isScanning) {
974
1304
  stopScan()
975
1305
  emitStopScanEvent()
@@ -1013,11 +1343,13 @@ class MentraLive : SGCManager() {
1013
1343
 
1014
1344
  /** Stops BLE scanning */
1015
1345
  override fun stopScan() {
1346
+ manualDiscoveryActive = false
1016
1347
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
1017
1348
  return
1018
1349
  }
1019
1350
 
1020
1351
  try {
1352
+ scanGeneration++
1021
1353
  bluetoothScanner!!.stopScan(scanCallback)
1022
1354
  isScanning = false
1023
1355
  DeviceStore.apply("bluetooth", "searching", false)
@@ -1041,6 +1373,48 @@ class MentraLive : SGCManager() {
1041
1373
  Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
1042
1374
  }
1043
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
+
1044
1418
  var seenDevices: MutableSet<String> = HashSet()
1045
1419
 
1046
1420
  /** BLE Scan callback */
@@ -1093,42 +1467,78 @@ class MentraLive : SGCManager() {
1093
1467
  deviceName.startsWith("MENTRA_LIVE_BT") ||
1094
1468
  deviceName.lowercase().startsWith("mentra_live")
1095
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
+ }
1096
1498
  val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
1499
+ val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
1097
1500
  Bridge.log(
1098
1501
  "LIVE: Found compatible " +
1099
1502
  glassType +
1100
1503
  " glasses device: " +
1101
- deviceName
1504
+ deviceName +
1505
+ if (secureCapable) " (pairingMode=$pairingMode)"
1506
+ else " (legacy firmware, pairable)"
1102
1507
  )
1103
- // EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
1104
- // smartGlassesDevice.deviceModelName, deviceName));
1105
1508
  Bridge.sendDiscoveredDevice(
1106
1509
  DeviceTypes.LIVE,
1107
1510
  deviceName,
1108
1511
  deviceAddress,
1109
- 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,
1110
1518
  )
1111
1519
 
1112
- // If this is the specific device we want to connect to by name, connect to
1113
- // it
1114
- 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) {
1115
1523
  Log.i(
1116
1524
  TAG,
1117
- "🔌 🎯 RECONNECT TARGET FOUND - Device: " +
1525
+ "🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
1118
1526
  deviceName +
1119
1527
  " (Attempt #" +
1120
1528
  reconnectAttempts +
1121
1529
  ")"
1122
1530
  )
1123
1531
  Bridge.log(
1124
- "LIVE: 🔌 🎯 Found our remembered device by name, connecting: " +
1532
+ "LIVE: 🔌 🎯 Found target device by name, connecting: " +
1125
1533
  deviceName +
1126
- " (Reconnect attempt #" +
1127
- reconnectAttempts +
1128
- ")"
1534
+ (if (isReconnecting)
1535
+ " (Reconnect attempt #" +
1536
+ reconnectAttempts +
1537
+ ")"
1538
+ else " (connectById)")
1129
1539
  )
1130
1540
  synchronized(connectionLock) {
1131
- if (isConnected || isConnecting) {
1541
+ if (pairingYieldActive || isConnected || isConnecting) {
1132
1542
  return
1133
1543
  }
1134
1544
  isConnecting = true
@@ -1136,6 +1546,7 @@ class MentraLive : SGCManager() {
1136
1546
  stopScan()
1137
1547
  emitStopScanEvent()
1138
1548
  isReconnecting = false
1549
+ explicitConnectByName = false
1139
1550
  connectToDevice(device)
1140
1551
  }
1141
1552
  }
@@ -1217,11 +1628,47 @@ class MentraLive : SGCManager() {
1217
1628
  return false
1218
1629
  }
1219
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
+
1220
1657
  /** Connect to a specific BLE device */
1221
1658
  private fun connectToDevice(device: BluetoothDevice?) {
1222
1659
  if (device == null) {
1223
1660
  return
1224
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
+ )
1225
1672
 
1226
1673
  // Cancel any previous connection timeouts
1227
1674
  if (connectionTimeoutRunnable != null) {
@@ -1321,6 +1768,85 @@ class MentraLive : SGCManager() {
1321
1768
  // }
1322
1769
  // }
1323
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
+
1324
1850
  /** Handle reconnection with exponential backoff */
1325
1851
  private fun handleReconnection() {
1326
1852
  // Don't attempt reconnection if we've been killed/forgotten
@@ -1330,6 +1856,20 @@ class MentraLive : SGCManager() {
1330
1856
  return
1331
1857
  }
1332
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
+
1333
1873
  if (isReconnecting) {
1334
1874
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
1335
1875
  return
@@ -1401,10 +1941,25 @@ class MentraLive : SGCManager() {
1401
1941
  "ms)"
1402
1942
  )
1403
1943
 
1404
- // 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
1405
1947
  handler.postDelayed(
1406
1948
  object : Runnable {
1407
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
+ }
1408
1963
  if (!isConnected && !isConnecting && !isKilled) {
1409
1964
  // Prefer saved MAC for direct GATT connect (faster and more reliable
1410
1965
  // than scanning).
@@ -1554,9 +2109,11 @@ class MentraLive : SGCManager() {
1554
2109
  Bridge.log(
1555
2110
  "LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
1556
2111
  )
2112
+ markPairingTiming("gatt_connected")
1557
2113
  isConnecting = false
1558
2114
  isConnected = true
1559
2115
  connectedDevice = gatt.device
2116
+ emitConnectedPendingDeviceForPairingScan()
1560
2117
 
1561
2118
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
1562
2119
  // Persist MAC so reconnection can use direct GATT instead of scanning
@@ -1581,42 +2138,27 @@ class MentraLive : SGCManager() {
1581
2138
  // connectedDevice.getName());
1582
2139
  // }
1583
2140
 
1584
- // CTKD Implementation: Register bonding receiver and create bond for BT
1585
- // Classic
2141
+ // Register early so we don't miss bond broadcasts, but defer
2142
+ // createBond / A2DP until BLE GATT setup finishes (see
2143
+ // maybeInitiateCtkdAudioAfterBleReady).
1586
2144
  registerBondingReceiver()
1587
- bondingRetryCount = 0 // Reset retry counter for new connection
2145
+ bondingRetryCount = 0
2146
+ ctkdInitiatedThisGattSession = false
1588
2147
  Bridge.log(
1589
- "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}"
1590
2153
  )
1591
-
1592
- // Check if device is already bonded before attempting to create bond
1593
- if (connectedDevice!!.bondState == BluetoothDevice.BOND_BONDED) {
1594
- Bridge.log(
1595
- "LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
1596
- )
1597
- // Device is bonded but we need to explicitly connect the A2DP audio
1598
- // profile
1599
- // Just being bonded doesn't mean the audio profile is connected
1600
- connectA2dpProfile(connectedDevice!!)
1601
- // Note: audioConnected will be set to true once A2DP profile
1602
- // connects
1603
- } else {
1604
- createBond(connectedDevice!!)
1605
- }
1606
2154
 
1607
2155
  // Discover services
1608
2156
  gatt.discoverServices()
1609
2157
 
1610
- // Reset reconnect attempts on successful connection
1611
- val previousAttempts = reconnectAttempts
1612
- reconnectAttempts = 0
1613
- isReconnecting = false // Clear reconnection mode
1614
- Log.i(
1615
- TAG,
1616
- "🔌 ✅ Reconnection counter reset (was at " +
1617
- previousAttempts +
1618
- " attempts)"
1619
- )
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
1620
2162
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
1621
2163
  Log.w(
1622
2164
  TAG,
@@ -1625,6 +2167,10 @@ class MentraLive : SGCManager() {
1625
2167
  Bridge.log(
1626
2168
  "LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
1627
2169
  )
2170
+ endPairingTiming(
2171
+ "gatt_disconnected",
2172
+ "queueSize=${sendQueue.size}"
2173
+ )
1628
2174
  isConnected = false
1629
2175
  isConnecting = false
1630
2176
 
@@ -1635,6 +2181,7 @@ class MentraLive : SGCManager() {
1635
2181
 
1636
2182
  // Reset audio pairing flags
1637
2183
  glassesReadyReceived = false
2184
+ ctkdInitiatedThisGattSession = false
1638
2185
  audioConnected = false
1639
2186
 
1640
2187
  notificationsEnabled = false
@@ -1693,6 +2240,10 @@ class MentraLive : SGCManager() {
1693
2240
  status +
1694
2241
  ") - Will retry reconnection"
1695
2242
  )
2243
+ endPairingTiming(
2244
+ "gatt_error",
2245
+ "status=$status queueSize=${sendQueue.size}"
2246
+ )
1696
2247
  isConnected = false
1697
2248
  isConnecting = false
1698
2249
  glassesReady = false
@@ -1700,6 +2251,7 @@ class MentraLive : SGCManager() {
1700
2251
  readinessCompletedThisBleSession = false
1701
2252
  glassesReadyReceived = false
1702
2253
  audioConnected = false
2254
+ ctkdInitiatedThisGattSession = false
1703
2255
 
1704
2256
  notificationsEnabled = false
1705
2257
 
@@ -1723,6 +2275,19 @@ class MentraLive : SGCManager() {
1723
2275
  // Clean up resources
1724
2276
  closeGattQuietly(false)
1725
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
+
1726
2291
  // Attempt reconnection if not killed
1727
2292
  if (!isKilled) {
1728
2293
  Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
@@ -1773,24 +2338,47 @@ class MentraLive : SGCManager() {
1773
2338
  txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
1774
2339
  rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
1775
2340
 
1776
- // 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.
1777
2343
  lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
1778
2344
  lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
1779
2345
 
1780
- // Check if we have required characteristics
2346
+ // Match iOS: only the core transport pair is required for readiness.
1781
2347
  val hasRequiredCharacteristics =
1782
- (rxCharacteristic != null && txCharacteristic != null) &&
1783
- (lc3ReadCharacteristic != null &&
1784
- lc3WriteCharacteristic != null)
2348
+ hasRequiredMentraLiveCoreCharacteristics(
2349
+ hasRxCharacteristic = rxCharacteristic != null,
2350
+ hasTxCharacteristic = txCharacteristic != null,
2351
+ )
2352
+ val hasLc3AudioCharacteristics =
2353
+ lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
1785
2354
 
1786
2355
  if (hasRequiredCharacteristics) {
2356
+ if (!hasLc3AudioCharacteristics) {
2357
+ Bridge.log(
2358
+ "LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
2359
+ )
2360
+ }
1787
2361
  // BLE connection established, but we still need to wait for glasses
1788
2362
  // SOC
1789
2363
  Bridge.log(
1790
- "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
+ ")"
1791
2367
  )
2368
+ markPairingTiming("ble_chars_ready")
1792
2369
  Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
1793
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
+
1794
2382
  // Don't set connected=true here - wait for SOC to be ready
1795
2383
  // (fullyBooted=true)
1796
2384
  // DeviceStore handles connected state based on fullyBooted
@@ -1831,13 +2419,6 @@ class MentraLive : SGCManager() {
1831
2419
  if (txCharacteristic == null) {
1832
2420
  Log.e(TAG, "TX characteristic (peripheral's RX) not found")
1833
2421
  }
1834
- // Log LC3 characteristic errors
1835
- if (lc3ReadCharacteristic == null) {
1836
- Log.e(TAG, "LC3_READ characteristic not found")
1837
- }
1838
- if (lc3WriteCharacteristic == null) {
1839
- Log.e(TAG, "LC3_WRITE characteristic not found")
1840
- }
1841
2422
  gatt.disconnect()
1842
2423
  }
1843
2424
  } else {
@@ -1944,6 +2525,10 @@ class MentraLive : SGCManager() {
1944
2525
  gatt: BluetoothGatt,
1945
2526
  characteristic: BluetoothGattCharacteristic
1946
2527
  ) {
2528
+ if (!isCurrentGattCallback(gatt)) {
2529
+ return
2530
+ }
2531
+
1947
2532
  // Get thread ID for tracking thread issues
1948
2533
  val threadId = Thread.currentThread().id
1949
2534
  val uuid = characteristic.uuid
@@ -1977,20 +2562,6 @@ class MentraLive : SGCManager() {
1977
2562
 
1978
2563
  if (isRxCharacteristic) {
1979
2564
  Bridge.log("LIVE: Received data on RX characteristic")
1980
- // #region agent log [810da2] Hypothesis A+C: capture data.length vs
1981
- // negotiated MTU
1982
- Bridge.log(
1983
- "LIVE: [DEBUG-810da2-HypAC] RX dataLen=" +
1984
- data.size +
1985
- " mtu=" +
1986
- currentMtu +
1987
- " firstByte=0x" +
1988
- String.format("%02X", data[0]) +
1989
- " second=0x" +
1990
- (if (data.size > 1) String.format("%02X", data[1])
1991
- else "??")
1992
- )
1993
- // #endregion
1994
2565
  } else if (isTxCharacteristic) {
1995
2566
  Bridge.log("LIVE: Received data on TX characteristic")
1996
2567
  } else if (isLc3ReadCharacteristic) {
@@ -2095,6 +2666,9 @@ class MentraLive : SGCManager() {
2095
2666
  startSignalStrengthPolling()
2096
2667
  handler.post(processSendQueueRunnable!!)
2097
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()
2098
2672
  return
2099
2673
  }
2100
2674
 
@@ -3068,25 +3642,6 @@ class MentraLive : SGCManager() {
3068
3642
  processJsonMessage(expanded)
3069
3643
  } else {
3070
3644
  Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
3071
- // #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
3072
- // data length
3073
- val declaredPayloadLen =
3074
- if (data.size >= 5)
3075
- (((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF))
3076
- else -1
3077
- Bridge.log(
3078
- "LIVE: [DEBUG-810da2-HypAB] K900 PARSE FAILED thread=" +
3079
- threadId +
3080
- " dataLen=" +
3081
- data.size +
3082
- " mtu=" +
3083
- currentMtu +
3084
- " declaredPayloadLen=" +
3085
- declaredPayloadLen +
3086
- " expectedTotal=" +
3087
- (declaredPayloadLen + 7)
3088
- )
3089
- // #endregion
3090
3645
  }
3091
3646
 
3092
3647
  return // Exit after processing K900 protocol format
@@ -3260,6 +3815,24 @@ class MentraLive : SGCManager() {
3260
3815
  "pong" ->
3261
3816
  // Process heartbeat pong response
3262
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
+ )
3263
3836
  "imu_response",
3264
3837
  "imu_stream_response",
3265
3838
  "imu_gesture_response",
@@ -3648,7 +4221,19 @@ class MentraLive : SGCManager() {
3648
4221
  }
3649
4222
  "glasses_ready" -> {
3650
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
+ }
3651
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
+ )
3652
4237
 
3653
4238
  // glasses_ready is a REMOTE wire-session reset: the glasses ran
3654
4239
  // onTransportReset() before sending it, so their side is back on legacy
@@ -3667,9 +4252,9 @@ class MentraLive : SGCManager() {
3667
4252
  // Set the ready flag to stop any future readiness checks
3668
4253
  glassesReady = true
3669
4254
  glassesReadyReceived = true
3670
- // NOTE: Don't set fullyBooted here - it will be set when BOTH glasses_ready
3671
- // AND audioConnected are true (see below). This ensures BT Classic pairing
3672
- // is complete before the device is considered "paired" in MentraOS.
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).
3673
4258
 
3674
4259
  // Stop the readiness check loop since we got confirmation
3675
4260
  stopReadinessCheckLoop()
@@ -3789,19 +4374,21 @@ class MentraLive : SGCManager() {
3789
4374
  Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
3790
4375
  }
3791
4376
 
3792
- // Audio Pairing: Only mark as fully connected if audio is also ready
3793
- // On Android, CTKD automatically pairs BT Classic when BLE bonds, so audio is
3794
- // always ready
3795
- // This check maintains platform parity with iOS
3796
- if (audioConnected) {
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) {
3797
4386
  Bridge.log(
3798
- "LIVE: Audio: Both glasses_ready and audio connected - marking as fully connected"
4387
+ "LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
3799
4388
  )
3800
- DeviceStore.apply("glasses", "fullyBooted", true)
3801
- updateConnectionState(ConnTypes.CONNECTED)
3802
- } else {
3803
- Bridge.log(
3804
- "LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"
4389
+ markPairingTiming(
4390
+ "waiting_ctkd_bond",
4391
+ "queueSize=${sendQueue.size}"
3805
4392
  )
3806
4393
  }
3807
4394
  }
@@ -4115,6 +4702,9 @@ class MentraLive : SGCManager() {
4115
4702
  (fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4116
4703
  DeviceStore.apply("glasses", "bluetoothMacAddress", it)
4117
4704
  }
4705
+ (fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4706
+ DeviceStore.apply("glasses", "wifiMacAddress", it)
4707
+ }
4118
4708
  (fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
4119
4709
  DeviceStore.apply("glasses", "serialNumber", it)
4120
4710
  }
@@ -4124,6 +4714,12 @@ class MentraLive : SGCManager() {
4124
4714
  DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
4125
4715
  }
4126
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
+ }
4127
4723
 
4128
4724
  // Negotiate wire caps from ANY version_info chunk, not only the one
4129
4725
  // carrying build_number: the glasses put wire_caps in version_info_3
@@ -4480,6 +5076,10 @@ class MentraLive : SGCManager() {
4480
5076
  }
4481
5077
  if (ready == 1) {
4482
5078
  Bridge.log("LIVE: K900 SOC ready")
5079
+ markPairingTiming(
5080
+ "soc_ready",
5081
+ "glassesReady=$glassesReady queueSize=${sendQueue.size}"
5082
+ )
4483
5083
  // Only send phone_ready if we haven't already established connection
4484
5084
  // This prevents re-initialization on every heartbeat after initial
4485
5085
  // connection
@@ -5187,6 +5787,8 @@ class MentraLive : SGCManager() {
5187
5787
 
5188
5788
  /** Start the micbeat mechanism - periodically enable custom audio TX */
5189
5789
  private fun startMicBeat() {
5790
+ // A resumed custom-audio stream starts a new sequence domain.
5791
+ DeviceManager.getInstance().resetMicSequenceBaseline()
5190
5792
  micOnCount++
5191
5793
  Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
5192
5794
  micBeatCount = 0
@@ -5328,8 +5930,16 @@ class MentraLive : SGCManager() {
5328
5930
  override fun findCompatibleDevices() {
5329
5931
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5330
5932
 
5331
- // 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
5332
5940
  isReconnecting = false
5941
+ // Discovery-only: do not treat leftover savedDeviceName as a connect target.
5942
+ explicitConnectByName = false
5333
5943
 
5334
5944
  if (bluetoothAdapter == null) {
5335
5945
  Log.e(TAG, "Bluetooth not available")
@@ -5341,50 +5951,124 @@ class MentraLive : SGCManager() {
5341
5951
  return
5342
5952
  }
5343
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
+
5344
5959
  // Start scanning for BLE devices
5345
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
+ }
5346
5992
  }
5347
5993
 
5348
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
5349
6005
  Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
5350
6006
  savedDeviceName = id
5351
- // // Persist immediately so reconnection logic can find it in case this connection fails
5352
- // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5353
- // prefs.edit().putString(PREF_DEVICE_NAME, id).apply();
5354
- // Log.i(TAG, "🔌 💾 Saved device name for future reconnection: " +
5355
- // connectedDevice.getName());
5356
- // Bridge.log("LIVE: Saved device name for future reconnection: " +
5357
- // connectedDevice.getName());
6007
+ // Without this, connectToSmartGlasses() falls back to a name scan with
6008
+ // isReconnecting=false and never GATT-connects (loading screen hangs).
6009
+ explicitConnectByName = true
5358
6010
  connectToSmartGlasses()
5359
6011
  }
5360
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
+
5361
6023
  override fun forget() {
5362
6024
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
5363
6025
 
5364
- // Clear saved device name to prevent reconnection to this device
5365
- // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5366
- // prefs.edit().remove(PREF_DEVICE_NAME).apply();
5367
- // Bridge.log("LIVE: Cleared saved device name");
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 = ""
5368
6029
 
5369
6030
  // Reset reconnection state
5370
6031
  reconnectAttempts = 0
5371
6032
  isReconnecting = false
6033
+ manualDiscoveryActive = false
6034
+ explicitConnectByName = false
5372
6035
 
5373
- // Remove BT Classic bond - this is the ONLY place where we unbond,
5374
- // ensuring bond is only removed when user explicitly unpairs
5375
- if (connectedDevice != null) {
5376
- Bridge.log("LIVE: CTKD: Removing BT bond on explicit unpair")
5377
- removeBond(connectedDevice!!)
5378
- }
5379
-
6036
+ val ctx = context ?: Bridge.getContext()
6037
+ val bondedAddress = connectedDevice?.address
5380
6038
  if (isScanning) {
5381
6039
  stopScan()
5382
6040
  emitStopScanEvent()
5383
6041
  }
5384
- 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)
5385
6065
  }
5386
6066
 
5387
6067
  override fun disconnect() {
6068
+ if (unpairFlushPending) {
6069
+ Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6070
+ return
6071
+ }
5388
6072
  Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
5389
6073
  destroy()
5390
6074
  }
@@ -5422,6 +6106,10 @@ class MentraLive : SGCManager() {
5422
6106
  }
5423
6107
 
5424
6108
  fun connectToSmartGlasses() {
6109
+ if (pairingYieldActive) {
6110
+ Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6111
+ return
6112
+ }
5425
6113
  Bridge.log("LIVE: Connecting to Mentra Live glasses")
5426
6114
  updateConnectionState(ConnTypes.CONNECTING)
5427
6115
 
@@ -5450,7 +6138,13 @@ class MentraLive : SGCManager() {
5450
6138
  // var context = Bridge.getContext();
5451
6139
  // SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
5452
6140
  // String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
5453
- 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?)
5454
6148
 
5455
6149
  if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
5456
6150
  // Connect to last known device if available
@@ -5461,6 +6155,7 @@ class MentraLive : SGCManager() {
5461
6155
  Bridge.log(
5462
6156
  "LIVE: Found saved device, connecting directly: " + lastDeviceAddress
5463
6157
  )
6158
+ prepareClassicBeforeBleConnect(device)
5464
6159
  connectToDevice(device)
5465
6160
  } else {
5466
6161
  Bridge.log(
@@ -5476,12 +6171,62 @@ class MentraLive : SGCManager() {
5476
6171
  startScan() // Fallback to scanning
5477
6172
  }
5478
6173
  } else {
5479
- // If no last known device, start scanning for devices
5480
- Bridge.log("LIVE: No last known device, starting scan")
5481
- 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
+ }
5482
6191
  }
5483
6192
  }
5484
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
6206
+ }
6207
+ }
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
+
5485
6230
  override fun setMicEnabled(enabled: Boolean) {
5486
6231
  Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
5487
6232
 
@@ -5925,41 +6670,67 @@ class MentraLive : SGCManager() {
5925
6670
  Bridge.log(
5926
6671
  "LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
5927
6672
  )
6673
+ markPairingTiming(
6674
+ "ctkd_bonded",
6675
+ "glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
6676
+ )
5928
6677
  if (isKilled) {
5929
6678
  Bridge.log(
5930
6679
  "LIVE: CTKD: Ignoring bond complete — SGC destroyed"
5931
6680
  )
5932
6681
  return@run
5933
6682
  }
5934
- isBtClassicConnected = true
5935
- audioConnected = true
5936
6683
  bondingRetryCount =
5937
6684
  0 // Reset retry counter on success
5938
- // Both BLE and BT Classic are now connected via
5939
- // CTKD
5940
-
5941
- // If glasses_ready was already received, now we're
5942
- // fully ready
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.
5943
6694
  if (glassesReadyReceived) {
5944
- Bridge.log(
5945
- "LIVE: Audio: Both audio and glasses_ready confirmed - marking as fully connected"
5946
- )
5947
- DeviceStore.apply(
5948
- "glasses",
5949
- "fullyBooted",
5950
- true
5951
- )
5952
- updateConnectionState(ConnTypes.CONNECTED)
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
+ }
5953
6724
  }
5954
-
5955
- // Send audio connected event for platform parity
5956
- // with iOS
5957
- Bridge.sendAudioConnected(device.name)
5958
6725
  }
5959
6726
  BluetoothDevice.BOND_NONE -> {
5960
6727
  Bridge.log(
5961
6728
  "LIVE: CTKD: ❌ Bonding failed or removed for device"
5962
6729
  )
6730
+ markPairingTiming(
6731
+ "ctkd_bond_none",
6732
+ "prev=$previousBondState queueSize=${sendQueue.size}"
6733
+ )
5963
6734
  isBtClassicConnected = false
5964
6735
  audioConnected = false
5965
6736
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
@@ -6020,10 +6791,12 @@ class MentraLive : SGCManager() {
6020
6791
  Bridge.sendAudioDisconnected()
6021
6792
  }
6022
6793
  }
6023
- BluetoothDevice.BOND_BONDING ->
6794
+ BluetoothDevice.BOND_BONDING -> {
6024
6795
  Bridge.log(
6025
6796
  "LIVE: CTKD: 🔄 Bonding in progress with device"
6026
6797
  )
6798
+ markPairingTiming("ctkd_bonding")
6799
+ }
6027
6800
  else ->
6028
6801
  Bridge.log(
6029
6802
  "LIVE: CTKD: Unknown bond state: " + bondState
@@ -6058,6 +6831,48 @@ class MentraLive : SGCManager() {
6058
6831
  }
6059
6832
  }
6060
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
+
6061
6876
  /**
6062
6877
  * Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
6063
6878
  * BLE and BT Classic connections automatically
@@ -6164,6 +6979,7 @@ class MentraLive : SGCManager() {
6164
6979
 
6165
6980
  if (state == BluetoothProfile.STATE_CONNECTED) {
6166
6981
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
6982
+ a2dpConnectAttempts = 0
6167
6983
  markAudioConnected(device.name)
6168
6984
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
6169
6985
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
@@ -6172,35 +6988,48 @@ class MentraLive : SGCManager() {
6172
6988
  BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
6173
6989
  val result = connectMethod.invoke(a2dpProfile, device) as Boolean
6174
6990
  Bridge.log("LIVE: A2DP: Connect initiated, result: " + result)
6175
-
6176
- // Note: connect() is async. We mark as connected optimistically because:
6177
- // 1. The device is already bonded, so connection should succeed
6178
- // 2. Android will handle the actual A2DP connection in the background
6179
- // 3. If it fails, the user can still use BLE audio (LC3)
6180
- markAudioConnected(device.name)
6991
+ if (result) {
6992
+ scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
6993
+ } else {
6994
+ scheduleA2dpConnectRetry(device, "connect() returned false")
6995
+ }
6181
6996
  } else if (state == BluetoothProfile.STATE_CONNECTING) {
6182
- Bridge.log("LIVE: A2DP: Already connecting, marking audio as connected")
6183
- markAudioConnected(device.name)
6997
+ Bridge.log("LIVE: A2DP: Already connecting — waiting for STATE_CONNECTED")
6998
+ scheduleA2dpConnectRetry(device, "still connecting")
6184
6999
  } else {
6185
- // STATE_DISCONNECTING - wait and retry
6186
- Bridge.log("LIVE: A2DP: Device disconnecting, will retry in 500ms")
6187
- handler.postDelayed(
6188
- Runnable {
6189
- if (isKilled) {
6190
- return@Runnable
6191
- }
6192
- if (connectedDevice != null && a2dpProfile != null) {
6193
- connectA2dpWithProxy(connectedDevice)
6194
- }
6195
- },
6196
- 500
6197
- )
7000
+ scheduleA2dpConnectRetry(device, "device disconnecting")
6198
7001
  }
6199
7002
  } catch (e: Exception) {
6200
7003
  Bridge.log("LIVE: A2DP: Error connecting: " + e.message)
6201
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6202
- markAudioConnected(device.name)
7004
+ scheduleA2dpConnectRetry(device, "exception: ${e.message}")
7005
+ }
7006
+ }
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
6203
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
+ )
6204
7033
  }
6205
7034
 
6206
7035
  /** Helper to mark audio as connected and notify */
@@ -6218,8 +7047,14 @@ class MentraLive : SGCManager() {
6218
7047
  Bridge.log(
6219
7048
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
6220
7049
  )
7050
+ endPairingTiming(
7051
+ "fully_connected",
7052
+ "via=a2dp+glasses_ready queueSize=${sendQueue.size}"
7053
+ )
6221
7054
  DeviceStore.apply("glasses", "fullyBooted", true)
6222
7055
  updateConnectionState(ConnTypes.CONNECTED)
7056
+ } else {
7057
+ markPairingTiming("a2dp_connected_waiting_glasses_ready")
6223
7058
  }
6224
7059
  }
6225
7060
 
@@ -6243,6 +7078,7 @@ class MentraLive : SGCManager() {
6243
7078
  }
6244
7079
 
6245
7080
  Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
7081
+ a2dpConnectAttempts = 0
6246
7082
 
6247
7083
  // If we already have the proxy, try to connect directly
6248
7084
  if (a2dpProfile != null) {
@@ -6263,16 +7099,133 @@ class MentraLive : SGCManager() {
6263
7099
  Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
6264
7100
  } else {
6265
7101
  Bridge.log(
6266
- "LIVE: A2DP: Failed to get profile proxy, marking audio connected anyway"
7102
+ "LIVE: A2DP: Failed to get profile proxy — Classic audio not connected"
6267
7103
  )
6268
- // Still mark as connected - device is bonded and BLE audio (LC3) will work
6269
- markAudioConnected(device.name)
6270
7104
  }
6271
7105
  } else {
6272
7106
  Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
6273
7107
  }
6274
7108
  }
6275
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
+
6276
7229
  /** Close the A2DP profile proxy */
6277
7230
  private fun closeA2dpProxy() {
6278
7231
  if (a2dpProfile != null && bluetoothAdapter != null) {
@@ -6288,6 +7241,14 @@ class MentraLive : SGCManager() {
6288
7241
 
6289
7242
  // Mark as killed to prevent reconnection attempts
6290
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
6291
7252
 
6292
7253
  // Stop scanning if in progress
6293
7254
  if (isScanning) {
@@ -6298,7 +7259,10 @@ class MentraLive : SGCManager() {
6298
7259
  // CTKD Implementation: Unregister bonding receiver
6299
7260
  unregisterBondingReceiver()
6300
7261
 
6301
- // 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)
6302
7266
  closeA2dpProxy()
6303
7267
 
6304
7268
  // Stop readiness check loop
@@ -6369,6 +7333,9 @@ class MentraLive : SGCManager() {
6369
7333
  reconnectAttempts = 0
6370
7334
  isReconnecting = false
6371
7335
  glassesReady = false
7336
+ glassesReadyReceived = false
7337
+ audioConnected = false
7338
+ ctkdInitiatedThisGattSession = false
6372
7339
  DeviceStore.apply("glasses", "fullyBooted", false)
6373
7340
  updateConnectionState(ConnTypes.DISCONNECTED)
6374
7341
 
@@ -7388,6 +8355,7 @@ class MentraLive : SGCManager() {
7388
8355
  /** Sends phone_ready to (re)start the glasses-driven readiness flow. */
7389
8356
  private fun sendPhoneReady(reason: String) {
7390
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}")
7391
8359
  val readyMsg = JSONObject()
7392
8360
  readyMsg.put("type", "phone_ready")
7393
8361
  readyMsg.put("timestamp", System.currentTimeMillis())
@@ -7511,6 +8479,10 @@ class MentraLive : SGCManager() {
7511
8479
  payload
7512
8480
  )
7513
8481
  Bridge.log("LIVE: Sending BLE wire v2 handshake")
8482
+ markPairingTiming(
8483
+ "wire_handshake_sent",
8484
+ "attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
8485
+ )
7514
8486
  wireHandshakeQueued = true
7515
8487
  wireHandshakeSentGeneration = wireSessionGeneration
7516
8488
  queueData(packed, null)
@@ -7537,6 +8509,10 @@ class MentraLive : SGCManager() {
7537
8509
  WIRE_HANDSHAKE_MAX_ATTEMPTS +
7538
8510
  ") - re-arming"
7539
8511
  )
8512
+ markPairingTiming(
8513
+ "wire_handshake_unanswered",
8514
+ "attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
8515
+ )
7540
8516
  wireHandshakeQueued = false
7541
8517
  if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
7542
8518
  maybeSendWireHandshake()
@@ -7565,6 +8541,7 @@ class MentraLive : SGCManager() {
7565
8541
  Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
7566
8542
  return
7567
8543
  }
8544
+ markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
7568
8545
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
7569
8546
  }
7570
8547
 
@@ -8238,6 +9215,19 @@ class MentraLive : SGCManager() {
8238
9215
  }
8239
9216
  }
8240
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
+
8241
9231
  override fun sendSetSystemTime(timestampMs: Long) {
8242
9232
  Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
8243
9233
  try {
@@ -9425,6 +10415,21 @@ class MentraLive : SGCManager() {
9425
10415
  stopVideoRecording(requestId, null, null)
9426
10416
  }
9427
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
+
9428
10433
  override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
9429
10434
  Bridge.log(
9430
10435
  "LIVE: Stopping video recording: requestId=" +
@@ -9475,20 +10480,6 @@ class MentraLive : SGCManager() {
9475
10480
  val sequenceNumber = data[1]
9476
10481
  val receiveTime = System.currentTimeMillis()
9477
10482
 
9478
- // Basic sequence validation
9479
- if (lastReceivedLc3Sequence != (-1).toByte() &&
9480
- (lastReceivedLc3Sequence + 1).toByte() != sequenceNumber
9481
- ) {
9482
- Log.w(
9483
- TAG,
9484
- "LC3 packet sequence mismatch. Expected: " +
9485
- (lastReceivedLc3Sequence + 1) +
9486
- ", Got: " +
9487
- sequenceNumber
9488
- )
9489
- }
9490
- lastReceivedLc3Sequence = sequenceNumber
9491
-
9492
10483
  val lc3Data = Arrays.copyOfRange(data, 2, data.size)
9493
10484
 
9494
10485
  // Enhanced LC3 packet logging and saving
@@ -9500,7 +10491,11 @@ class MentraLive : SGCManager() {
9500
10491
 
9501
10492
  // Forward raw LC3 to DeviceManager (matches iOS behavior)
9502
10493
  // MentraLive uses 40-byte LC3 frames
9503
- DeviceManager.getInstance().handleGlassesMicData(lc3Data, LC3_FRAME_SIZE)
10494
+ DeviceManager.getInstance().handleGlassesMicData(
10495
+ lc3Data,
10496
+ LC3_FRAME_SIZE,
10497
+ sequenceNumber.toInt() and 0xff,
10498
+ )
9504
10499
 
9505
10500
  // Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
9506
10501
  // } else {