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