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