@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/android/build.gradle +7 -4
- package/android/lc3Lib/build.gradle +18 -4
- package/android/silero/build.gradle +9 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +39 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
- 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/MentraLive.kt +1174 -191
- 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/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +61 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +3 -12
- package/build/_private/sdkOtaManifest.d.ts.map +1 -1
- package/build/_private/sdkOtaManifest.js +5 -17
- package/build/_private/sdkOtaManifest.js.map +1 -1
- package/build/generated/releaseMetadata.d.ts +12 -0
- package/build/generated/releaseMetadata.d.ts.map +1 -0
- package/build/generated/releaseMetadata.js +10 -0
- package/build/generated/releaseMetadata.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -2
- package/build/index.js.map +1 -1
- package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
- package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServer.types.js +2 -0
- package/build/ota-server/MentraOtaServer.types.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
- package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.js +3 -0
- package/build/ota-server/MentraOtaServerModule.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.js +12 -0
- package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
- package/build/ota-server/index.d.ts +3 -0
- package/build/ota-server/index.d.ts.map +1 -0
- package/build/ota-server/index.js +3 -0
- package/build/ota-server/index.js.map +1 -0
- package/expo-module.config.json +3 -2
- package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
- package/ios/BluetoothSdkModule.swift +16 -2
- package/ios/LocalIPv4.swift +150 -0
- package/ios/LocalOtaServer.swift +309 -0
- package/ios/MentraBluetoothSDK.podspec +5 -1
- package/ios/MentraOtaServerModule.swift +147 -0
- package/ios/MentraPhotoReceiverModule.swift +1 -81
- package/ios/Source/BluetoothSdkDefaults.swift +4 -1
- package/ios/Source/Bridge.swift +47 -3
- package/ios/Source/DeviceManager.swift +135 -10
- 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 +439 -30
- package/ios/Source/sgcs/SGCManager.swift +11 -0
- package/ios/Source/status/DeviceStatus.swift +23 -0
- package/ios/Source/types/DeviceModels.swift +23 -2
- package/ios/Source/utils/BleJsonCompact.swift +73 -71
- package/package.json +5 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/verify-release-package.mjs +34 -0
- package/scripts/write-release-metadata.mjs +154 -0
- package/scripts/write-release-metadata.test.mjs +29 -0
- package/src/BluetoothSdk.types.ts +72 -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
|
|
@@ -76,6 +77,11 @@ import org.json.JSONArray
|
|
|
76
77
|
import org.json.JSONException
|
|
77
78
|
import org.json.JSONObject
|
|
78
79
|
|
|
80
|
+
internal fun hasRequiredMentraLiveCoreCharacteristics(
|
|
81
|
+
hasRxCharacteristic: Boolean,
|
|
82
|
+
hasTxCharacteristic: Boolean,
|
|
83
|
+
): Boolean = hasRxCharacteristic && hasTxCharacteristic
|
|
84
|
+
|
|
79
85
|
/**
|
|
80
86
|
* Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
|
|
81
87
|
* glasses
|
|
@@ -83,6 +89,12 @@ import org.json.JSONObject
|
|
|
83
89
|
* Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
|
|
84
90
|
* display-related methods are stubbed out and will log a message but not actually display anything.
|
|
85
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
|
+
|
|
86
98
|
class MentraLive : SGCManager() {
|
|
87
99
|
private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
|
|
88
100
|
|
|
@@ -99,6 +111,9 @@ class MentraLive : SGCManager() {
|
|
|
99
111
|
private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
|
|
100
112
|
private const val LOUDNESS_GATE_SWITCH_TYPE = 10
|
|
101
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
|
|
102
117
|
|
|
103
118
|
// L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
|
|
104
119
|
// PSM. When the phone opens the channel, the glasses send file packets over it instead
|
|
@@ -142,6 +157,287 @@ class MentraLive : SGCManager() {
|
|
|
142
157
|
10000 // 10 seconds for reconnection scans (faster than 60s default)
|
|
143
158
|
private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
|
|
144
159
|
10_000L // 10s before first scan after shutdown
|
|
160
|
+
|
|
161
|
+
private const val MENTRA_MANUFACTURER_ID = 0xB822
|
|
162
|
+
private const val ADV_MANUF_PAIRING_FLAG_OFFSET = 5
|
|
163
|
+
private const val ADV_PAIRING_DISCOVERABLE: Byte = 0x01
|
|
164
|
+
private const val ADV_PAIRING_PROTOCOL_VERSION_MIN = 1
|
|
165
|
+
private const val ADV_PAIRING_PROTOCOL_VERSION_MAX = 15
|
|
166
|
+
|
|
167
|
+
private fun manufacturerData(result: ScanResult): ByteArray? {
|
|
168
|
+
return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* OS-1615 ads append `flag | version | capability | code_lo | code_hi` after the
|
|
173
|
+
* connected byte. Field firmware uses the same 0xB822 company id but then writes the
|
|
174
|
+
* XOR'd Classic MAC at those offsets. Length alone is not a pairing flag: treating MAC
|
|
175
|
+
* bytes as version/capability hides every old unit as "secure, not in pairing mode".
|
|
176
|
+
*/
|
|
177
|
+
private fun hasSecurePairingTrailer(manufData: ByteArray?): Boolean {
|
|
178
|
+
if (manufData == null) {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
val trailerBase = ADV_MANUF_PAIRING_FLAG_OFFSET + 1
|
|
182
|
+
if (manufData.size < trailerBase + 4) {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
val version = manufData[trailerBase].toInt() and 0xFF
|
|
186
|
+
val capability = manufData[trailerBase + 1].toInt() and 0xFF
|
|
187
|
+
return version in ADV_PAIRING_PROTOCOL_VERSION_MIN..ADV_PAIRING_PROTOCOL_VERSION_MAX &&
|
|
188
|
+
(capability and 0x01) != 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun isPairingDiscoverable(result: ScanResult): Boolean {
|
|
192
|
+
val manufData = manufacturerData(result) ?: return false
|
|
193
|
+
if (!hasSecurePairingTrailer(manufData)) {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
return manufData[ADV_MANUF_PAIRING_FLAG_OFFSET] == ADV_PAIRING_DISCOVERABLE
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun advertisesPairingFlag(result: ScanResult): Boolean {
|
|
200
|
+
return hasSecurePairingTrailer(manufacturerData(result))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Trailer immediately after pairing flag: version | capability | code_lo | code_hi */
|
|
204
|
+
private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
|
|
205
|
+
val manufData = manufacturerData(result) ?: return Triple(false, null, false)
|
|
206
|
+
if (!hasSecurePairingTrailer(manufData)) {
|
|
207
|
+
return Triple(false, null, false)
|
|
208
|
+
}
|
|
209
|
+
val pairingMode = manufData[ADV_MANUF_PAIRING_FLAG_OFFSET] == ADV_PAIRING_DISCOVERABLE
|
|
210
|
+
val trailerBase = ADV_MANUF_PAIRING_FLAG_OFFSET + 1
|
|
211
|
+
val codeLo = manufData[trailerBase + 2].toInt() and 0xFF
|
|
212
|
+
val codeHi = manufData[trailerBase + 3].toInt() and 0xFF
|
|
213
|
+
val code = String.format("%02X%02X", codeHi, codeLo)
|
|
214
|
+
return Triple(pairingMode, code, true)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fun isMentraLiveBluetoothName(name: String?): Boolean {
|
|
218
|
+
if (name.isNullOrEmpty()) {
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
return name == "Xy_A" ||
|
|
222
|
+
name.startsWith("XyBLE_") ||
|
|
223
|
+
name.startsWith("MENTRA_LIVE_BLE") ||
|
|
224
|
+
name.startsWith("MENTRA_LIVE_BT") ||
|
|
225
|
+
name.lowercase().startsWith("mentra_live")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private fun safeBondedDeviceName(device: BluetoothDevice): String {
|
|
229
|
+
return try {
|
|
230
|
+
device.name ?: ""
|
|
231
|
+
} catch (_: SecurityException) {
|
|
232
|
+
""
|
|
233
|
+
} catch (_: Exception) {
|
|
234
|
+
""
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private fun removeBondStandalone(device: BluetoothDevice): Boolean {
|
|
239
|
+
return try {
|
|
240
|
+
val method = device.javaClass.getMethod("removeBond")
|
|
241
|
+
val result = method.invoke(device) as Boolean
|
|
242
|
+
Bridge.log(
|
|
243
|
+
"LIVE: CTKD: removeBond " +
|
|
244
|
+
safeBondedDeviceName(device) +
|
|
245
|
+
" (" +
|
|
246
|
+
device.address +
|
|
247
|
+
") result=" +
|
|
248
|
+
result
|
|
249
|
+
)
|
|
250
|
+
result
|
|
251
|
+
} catch (e: Exception) {
|
|
252
|
+
Bridge.log("LIVE: CTKD: removeBond error: " + e.message)
|
|
253
|
+
false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fun invokeHiddenProfileDisconnectStandalone(
|
|
258
|
+
proxy: BluetoothProfile,
|
|
259
|
+
device: BluetoothDevice,
|
|
260
|
+
proxyClass: Class<*>,
|
|
261
|
+
label: String
|
|
262
|
+
) {
|
|
263
|
+
try {
|
|
264
|
+
val state = proxy.getConnectionState(device)
|
|
265
|
+
if (state == BluetoothProfile.STATE_CONNECTED ||
|
|
266
|
+
state == BluetoothProfile.STATE_CONNECTING
|
|
267
|
+
) {
|
|
268
|
+
val disconnectMethod =
|
|
269
|
+
proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
|
|
270
|
+
val result = disconnectMethod.invoke(proxy, device) as Boolean
|
|
271
|
+
Bridge.log(
|
|
272
|
+
"LIVE: Classic: " +
|
|
273
|
+
label +
|
|
274
|
+
" disconnect " +
|
|
275
|
+
device.address +
|
|
276
|
+
" result=" +
|
|
277
|
+
result
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
} catch (e: Exception) {
|
|
281
|
+
Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private fun requestProfileDisconnectStandalone(
|
|
286
|
+
context: Context,
|
|
287
|
+
adapter: BluetoothAdapter,
|
|
288
|
+
profile: Int,
|
|
289
|
+
device: BluetoothDevice,
|
|
290
|
+
proxyClass: Class<*>,
|
|
291
|
+
label: String
|
|
292
|
+
) {
|
|
293
|
+
try {
|
|
294
|
+
adapter.getProfileProxy(
|
|
295
|
+
context,
|
|
296
|
+
object : BluetoothProfile.ServiceListener {
|
|
297
|
+
override fun onServiceConnected(
|
|
298
|
+
profileType: Int,
|
|
299
|
+
proxy: BluetoothProfile
|
|
300
|
+
) {
|
|
301
|
+
if (profileType != profile) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
invokeHiddenProfileDisconnectStandalone(
|
|
306
|
+
proxy,
|
|
307
|
+
device,
|
|
308
|
+
proxyClass,
|
|
309
|
+
label
|
|
310
|
+
)
|
|
311
|
+
} finally {
|
|
312
|
+
try {
|
|
313
|
+
adapter.closeProfileProxy(profile, proxy)
|
|
314
|
+
} catch (_: Exception) {
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
override fun onServiceDisconnected(profileType: Int) {}
|
|
320
|
+
},
|
|
321
|
+
profile
|
|
322
|
+
)
|
|
323
|
+
} catch (e: Exception) {
|
|
324
|
+
Bridge.log("LIVE: Classic: request " + label + " proxy failed: " + e.message)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Drop A2DP/HFP + removeBond for every bonded Mentra Live unit. Safe when no SGC
|
|
330
|
+
* instance exists — [DeviceManager.forget] often runs after disconnect() nulled sgc
|
|
331
|
+
* (abandonAttempt), which previously skipped MentraLive.forget() entirely and left
|
|
332
|
+
* Classic ACL up so glasses never re-advertised.
|
|
333
|
+
*/
|
|
334
|
+
@JvmStatic
|
|
335
|
+
@JvmOverloads
|
|
336
|
+
fun unbondBondedMentraLiveDevices(context: Context?, extraAddress: String? = null) {
|
|
337
|
+
if (context == null) {
|
|
338
|
+
Bridge.log("LIVE: CTKD: unbondBondedMentraLiveDevices — no context")
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
val adapter =
|
|
342
|
+
try {
|
|
343
|
+
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
|
344
|
+
?.adapter
|
|
345
|
+
} catch (e: Exception) {
|
|
346
|
+
Bridge.log("LIVE: CTKD: unbond — adapter error: " + e.message)
|
|
347
|
+
null
|
|
348
|
+
}
|
|
349
|
+
if (adapter == null) {
|
|
350
|
+
Bridge.log("LIVE: CTKD: unbond — BluetoothAdapter unavailable")
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
val bonded =
|
|
354
|
+
try {
|
|
355
|
+
adapter.bondedDevices?.toList().orEmpty()
|
|
356
|
+
} catch (e: Exception) {
|
|
357
|
+
Bridge.log("LIVE: CTKD: unbond — cannot list bonded: " + e.message)
|
|
358
|
+
emptyList()
|
|
359
|
+
}
|
|
360
|
+
val extra = extraAddress?.uppercase()
|
|
361
|
+
val targets =
|
|
362
|
+
bonded.filter {
|
|
363
|
+
isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
|
|
364
|
+
(extra != null && it.address.equals(extra, ignoreCase = true))
|
|
365
|
+
}
|
|
366
|
+
if (targets.isEmpty()) {
|
|
367
|
+
Bridge.log("LIVE: CTKD: unbond — no bonded Mentra Live devices")
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
for (device in targets) {
|
|
371
|
+
Bridge.log(
|
|
372
|
+
"LIVE: CTKD: tearing down bonded Mentra Live " +
|
|
373
|
+
safeBondedDeviceName(device) +
|
|
374
|
+
" (" +
|
|
375
|
+
device.address +
|
|
376
|
+
")"
|
|
377
|
+
)
|
|
378
|
+
requestProfileDisconnectStandalone(
|
|
379
|
+
context,
|
|
380
|
+
adapter,
|
|
381
|
+
BluetoothProfile.A2DP,
|
|
382
|
+
device,
|
|
383
|
+
BluetoothA2dp::class.java,
|
|
384
|
+
"A2DP"
|
|
385
|
+
)
|
|
386
|
+
requestProfileDisconnectStandalone(
|
|
387
|
+
context,
|
|
388
|
+
adapter,
|
|
389
|
+
BluetoothProfile.HEADSET,
|
|
390
|
+
device,
|
|
391
|
+
BluetoothHeadset::class.java,
|
|
392
|
+
"HFP"
|
|
393
|
+
)
|
|
394
|
+
removeBondStandalone(device)
|
|
395
|
+
}
|
|
396
|
+
Handler(Looper.getMainLooper())
|
|
397
|
+
.postDelayed({ retryUnbondIfStillPaired(context, extraAddress) }, 700)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private fun retryUnbondIfStillPaired(context: Context?, extraAddress: String?) {
|
|
401
|
+
if (context == null) {
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
val adapter =
|
|
405
|
+
try {
|
|
406
|
+
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
|
407
|
+
?.adapter
|
|
408
|
+
} catch (_: Exception) {
|
|
409
|
+
null
|
|
410
|
+
}
|
|
411
|
+
?: return
|
|
412
|
+
val extra = extraAddress?.uppercase()
|
|
413
|
+
val leftover =
|
|
414
|
+
try {
|
|
415
|
+
adapter.bondedDevices?.toList().orEmpty()
|
|
416
|
+
} catch (_: Exception) {
|
|
417
|
+
emptyList()
|
|
418
|
+
}
|
|
419
|
+
.filter {
|
|
420
|
+
isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
|
|
421
|
+
(extra != null &&
|
|
422
|
+
it.address.equals(extra, ignoreCase = true))
|
|
423
|
+
}
|
|
424
|
+
if (leftover.isEmpty()) {
|
|
425
|
+
Bridge.log("LIVE: CTKD: unbond verify — no Mentra Live bonds remain")
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
for (device in leftover) {
|
|
429
|
+
Bridge.log(
|
|
430
|
+
"LIVE: CTKD: unbond retry still paired " +
|
|
431
|
+
safeBondedDeviceName(device) +
|
|
432
|
+
" (" +
|
|
433
|
+
device.address +
|
|
434
|
+
") bondState=" +
|
|
435
|
+
device.bondState
|
|
436
|
+
)
|
|
437
|
+
removeBondStandalone(device)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
145
441
|
private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
|
|
146
442
|
|
|
147
443
|
// Keep-alive parameters
|
|
@@ -253,6 +549,16 @@ class MentraLive : SGCManager() {
|
|
|
253
549
|
|
|
254
550
|
private var reconnectAttempts = 0
|
|
255
551
|
private var isReconnecting = false // Track if we're in reconnection mode
|
|
552
|
+
// Manual discovery/connect invalidates delayed reconnect work from an earlier GATT failure.
|
|
553
|
+
private var reconnectGeneration = 0
|
|
554
|
+
// Pair Glasses discovery is an explicit no-reconnect zone. It ends only when
|
|
555
|
+
// the user selects a scan result (connectById) or the manager is torn down.
|
|
556
|
+
private var manualDiscoveryActive = false
|
|
557
|
+
// Set by connectById() so a post-disconnect name scan actually GATT-connects.
|
|
558
|
+
// Discovery-only scans (findCompatibleDevices) leave this false so a leftover
|
|
559
|
+
// savedDeviceName cannot auto-connect a spoofed advertiser (iOS parity: iOS
|
|
560
|
+
// connects on saved-name match because connectById writes that name first).
|
|
561
|
+
private var explicitConnectByName = false
|
|
256
562
|
/**
|
|
257
563
|
* Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
|
|
258
564
|
* so glasses can reboot.
|
|
@@ -276,18 +582,35 @@ class MentraLive : SGCManager() {
|
|
|
276
582
|
private val fileProcessingHandler = Handler(fileProcessingThread.looper)
|
|
277
583
|
private var scheduler: ScheduledExecutorService? = null
|
|
278
584
|
private var isScanning = false
|
|
585
|
+
// Prevent an old scan timeout from stopping a newer manual scan.
|
|
586
|
+
private var scanGeneration = 0
|
|
279
587
|
private var isConnecting = false
|
|
280
588
|
private var isKilled = false
|
|
281
589
|
|
|
590
|
+
/** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
|
|
591
|
+
private var pairingYieldActive = false
|
|
592
|
+
private var pairingYieldEndRunnable: Runnable? = null
|
|
593
|
+
/** After yield ends, auth/not-owner failures may clear saved glasses. */
|
|
594
|
+
private var pairingYieldAwaitingReclaim = false
|
|
595
|
+
/** Hold GATT teardown until the unpair write can leave the phone. */
|
|
596
|
+
private var unpairFlushPending = false
|
|
597
|
+
private var unpairFlushRunnable: Runnable? = null
|
|
598
|
+
|
|
282
599
|
// CTKD (Cross-Transport Key Derivation) support for BES devices
|
|
283
600
|
private var isBondingReceiverRegistered = false
|
|
284
601
|
private var isBtClassicConnected = false
|
|
285
602
|
private var bondingReceiver: BroadcastReceiver? = null
|
|
286
603
|
private var bondingRetryCount = 0
|
|
287
604
|
|
|
605
|
+
// Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
|
|
606
|
+
private var pairingTimingActive = false
|
|
607
|
+
private var pairingTimingStartMs = 0L
|
|
608
|
+
private var pairingTimingLastMs = 0L
|
|
609
|
+
|
|
288
610
|
// A2DP profile connection for already-bonded devices
|
|
289
611
|
private var a2dpProfile: BluetoothA2dp? = null
|
|
290
612
|
private var isA2dpProxyRegistered = false
|
|
613
|
+
private var a2dpConnectAttempts = 0
|
|
291
614
|
|
|
292
615
|
private data class OutgoingBleCommandTraceInfo(
|
|
293
616
|
val commandType: String,
|
|
@@ -609,6 +932,9 @@ class MentraLive : SGCManager() {
|
|
|
609
932
|
// Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
|
|
610
933
|
private var glassesReadyReceived = false
|
|
611
934
|
private var audioConnected = false
|
|
935
|
+
// CTKD/A2DP must wait until GATT notifications are armed; starting Classic while the
|
|
936
|
+
// BLE link is still discovering services commonly yields GATT status 19 on Samsung.
|
|
937
|
+
private var ctkdInitiatedThisGattSession = false
|
|
612
938
|
|
|
613
939
|
// Micbeat tracking - periodically enable custom audio TX
|
|
614
940
|
private var micBeatHandler = Handler(Looper.getMainLooper())
|
|
@@ -624,7 +950,6 @@ class MentraLive : SGCManager() {
|
|
|
624
950
|
private val secureRandom = SecureRandom()
|
|
625
951
|
private val deviceId = System.currentTimeMillis() xor Random().nextLong()
|
|
626
952
|
|
|
627
|
-
private var lastReceivedLc3Sequence: Byte = -1
|
|
628
953
|
private var lc3SequenceNumber: Byte = 0
|
|
629
954
|
private var lc3DecoderPtr = 0L
|
|
630
955
|
private var lc3AudioPlayer: Lc3Player? = null
|
|
@@ -811,6 +1136,9 @@ class MentraLive : SGCManager() {
|
|
|
811
1136
|
}
|
|
812
1137
|
|
|
813
1138
|
private fun updateConnectionState(state: String) {
|
|
1139
|
+
if (state == ConnTypes.DISCONNECTED) {
|
|
1140
|
+
DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
|
|
1141
|
+
}
|
|
814
1142
|
val isEqual = state == connectionState
|
|
815
1143
|
if (isEqual) {
|
|
816
1144
|
if (state == ConnTypes.DISCONNECTED) {
|
|
@@ -833,6 +1161,7 @@ class MentraLive : SGCManager() {
|
|
|
833
1161
|
// same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
|
|
834
1162
|
DeviceStore.apply("glasses", "serialNumber", "")
|
|
835
1163
|
DeviceStore.apply("glasses", "bluetoothMacAddress", "")
|
|
1164
|
+
DeviceStore.apply("glasses", "wifiMacAddress", "")
|
|
836
1165
|
}
|
|
837
1166
|
|
|
838
1167
|
// Actually update the connection state!
|
|
@@ -905,6 +1234,10 @@ class MentraLive : SGCManager() {
|
|
|
905
1234
|
|
|
906
1235
|
/** Starts BLE scanning for Mentra Live glasses */
|
|
907
1236
|
private fun startScan() {
|
|
1237
|
+
if (pairingYieldActive) {
|
|
1238
|
+
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
1239
|
+
return
|
|
1240
|
+
}
|
|
908
1241
|
if (bluetoothAdapter == null || isScanning) {
|
|
909
1242
|
return
|
|
910
1243
|
}
|
|
@@ -963,13 +1296,22 @@ class MentraLive : SGCManager() {
|
|
|
963
1296
|
)
|
|
964
1297
|
}
|
|
965
1298
|
|
|
1299
|
+
val generation = ++scanGeneration
|
|
966
1300
|
isScanning = true
|
|
967
1301
|
bluetoothScanner!!.startScan(filters, settings, scanCallback)
|
|
1302
|
+
// Connected glasses stop advertising (BLE_CONNECTION_MAX=1). Pairing scan
|
|
1303
|
+
// would otherwise return empty even though GATT is already up.
|
|
1304
|
+
if (!isReconnecting) {
|
|
1305
|
+
handler.post { emitConnectedDeviceForPairingScan() }
|
|
1306
|
+
}
|
|
968
1307
|
|
|
969
1308
|
// Set a timeout to stop scanning
|
|
970
1309
|
handler.postDelayed(
|
|
971
1310
|
object : Runnable {
|
|
972
1311
|
override fun run() {
|
|
1312
|
+
if (generation != scanGeneration) {
|
|
1313
|
+
return
|
|
1314
|
+
}
|
|
973
1315
|
if (isScanning) {
|
|
974
1316
|
stopScan()
|
|
975
1317
|
emitStopScanEvent()
|
|
@@ -1013,11 +1355,13 @@ class MentraLive : SGCManager() {
|
|
|
1013
1355
|
|
|
1014
1356
|
/** Stops BLE scanning */
|
|
1015
1357
|
override fun stopScan() {
|
|
1358
|
+
manualDiscoveryActive = false
|
|
1016
1359
|
if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
|
|
1017
1360
|
return
|
|
1018
1361
|
}
|
|
1019
1362
|
|
|
1020
1363
|
try {
|
|
1364
|
+
scanGeneration++
|
|
1021
1365
|
bluetoothScanner!!.stopScan(scanCallback)
|
|
1022
1366
|
isScanning = false
|
|
1023
1367
|
DeviceStore.apply("bluetooth", "searching", false)
|
|
@@ -1041,6 +1385,33 @@ class MentraLive : SGCManager() {
|
|
|
1041
1385
|
Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
|
|
1042
1386
|
}
|
|
1043
1387
|
|
|
1388
|
+
/**
|
|
1389
|
+
* Pairing scan listens for advertisements. A unit that is already GATT-connected
|
|
1390
|
+
* (typical after a CTKD retry / back-out on field firmware) has stopped ADV, so
|
|
1391
|
+
* emit it as pairable or the scan list stays empty.
|
|
1392
|
+
*/
|
|
1393
|
+
private fun emitConnectedDeviceForPairingScan() {
|
|
1394
|
+
if (!isConnected || isReconnecting || pairingYieldActive) {
|
|
1395
|
+
return
|
|
1396
|
+
}
|
|
1397
|
+
val device = connectedDevice ?: bluetoothGatt?.device ?: return
|
|
1398
|
+
val name = safeBondedDeviceName(device)
|
|
1399
|
+
if (!isMentraLiveBluetoothName(name)) {
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
Bridge.log(
|
|
1403
|
+
"LIVE: Pairing scan: already GATT-connected to $name — emitting as pairable (ADV off while connected)"
|
|
1404
|
+
)
|
|
1405
|
+
Bridge.sendDiscoveredDevice(
|
|
1406
|
+
DeviceTypes.LIVE,
|
|
1407
|
+
name,
|
|
1408
|
+
device.address ?: "",
|
|
1409
|
+
pairingMode = true,
|
|
1410
|
+
pairingCode = null,
|
|
1411
|
+
securePairingCapable = false,
|
|
1412
|
+
)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1044
1415
|
var seenDevices: MutableSet<String> = HashSet()
|
|
1045
1416
|
|
|
1046
1417
|
/** BLE Scan callback */
|
|
@@ -1093,42 +1464,77 @@ class MentraLive : SGCManager() {
|
|
|
1093
1464
|
deviceName.startsWith("MENTRA_LIVE_BT") ||
|
|
1094
1465
|
deviceName.lowercase().startsWith("mentra_live")
|
|
1095
1466
|
) {
|
|
1467
|
+
// Connect-by-name only when reconnecting OR connectById() asked for
|
|
1468
|
+
// this name. A discovery-only pairing scan must not auto-connect just
|
|
1469
|
+
// because savedDeviceName still matches (spoof risk).
|
|
1470
|
+
val isNamedConnectTarget =
|
|
1471
|
+
savedDeviceName.isNotEmpty() &&
|
|
1472
|
+
savedDeviceName == deviceName &&
|
|
1473
|
+
(isReconnecting || explicitConnectByName)
|
|
1474
|
+
if (!isReconnecting &&
|
|
1475
|
+
advertisesPairingFlag(result) &&
|
|
1476
|
+
!isPairingDiscoverable(result)
|
|
1477
|
+
) {
|
|
1478
|
+
// Tell RN a nearby unit is advertising but not pairable so the
|
|
1479
|
+
// empty-state hint can fire. The scan list never shows these.
|
|
1480
|
+
Bridge.log(
|
|
1481
|
+
"LIVE: Nearby $deviceName is secure firmware not in pairing mode — hiding from scan list"
|
|
1482
|
+
)
|
|
1483
|
+
Bridge.sendDiscoveredDevice(
|
|
1484
|
+
DeviceTypes.LIVE,
|
|
1485
|
+
deviceName,
|
|
1486
|
+
deviceAddress,
|
|
1487
|
+
result.rssi,
|
|
1488
|
+
pairingMode = false,
|
|
1489
|
+
pairingCode = null,
|
|
1490
|
+
securePairingCapable = true,
|
|
1491
|
+
)
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1096
1494
|
val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
|
|
1495
|
+
val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
|
|
1097
1496
|
Bridge.log(
|
|
1098
1497
|
"LIVE: Found compatible " +
|
|
1099
1498
|
glassType +
|
|
1100
1499
|
" glasses device: " +
|
|
1101
|
-
deviceName
|
|
1500
|
+
deviceName +
|
|
1501
|
+
if (secureCapable) " (pairingMode=$pairingMode)"
|
|
1502
|
+
else " (legacy firmware, pairable)"
|
|
1102
1503
|
)
|
|
1103
|
-
// EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
|
|
1104
|
-
// smartGlassesDevice.deviceModelName, deviceName));
|
|
1105
1504
|
Bridge.sendDiscoveredDevice(
|
|
1106
1505
|
DeviceTypes.LIVE,
|
|
1107
1506
|
deviceName,
|
|
1108
1507
|
deviceAddress,
|
|
1109
|
-
result.rssi
|
|
1508
|
+
result.rssi,
|
|
1509
|
+
// Field firmware has no pairing flag. Treat it as pairable so the
|
|
1510
|
+
// new Mentra App can still find old glasses.
|
|
1511
|
+
pairingMode = if (secureCapable) pairingMode else true,
|
|
1512
|
+
pairingCode = pairingCode,
|
|
1513
|
+
securePairingCapable = secureCapable,
|
|
1110
1514
|
)
|
|
1111
1515
|
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
if (
|
|
1516
|
+
// connectById / reconnect: user (or reconnect) asked for this name —
|
|
1517
|
+
// GATT-connect. Discovery-only scans never set explicitConnectByName.
|
|
1518
|
+
if (isNamedConnectTarget) {
|
|
1115
1519
|
Log.i(
|
|
1116
1520
|
TAG,
|
|
1117
|
-
"🔌 🎯
|
|
1521
|
+
"🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
|
|
1118
1522
|
deviceName +
|
|
1119
1523
|
" (Attempt #" +
|
|
1120
1524
|
reconnectAttempts +
|
|
1121
1525
|
")"
|
|
1122
1526
|
)
|
|
1123
1527
|
Bridge.log(
|
|
1124
|
-
"LIVE: 🔌 🎯 Found
|
|
1528
|
+
"LIVE: 🔌 🎯 Found target device by name, connecting: " +
|
|
1125
1529
|
deviceName +
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1530
|
+
(if (isReconnecting)
|
|
1531
|
+
" (Reconnect attempt #" +
|
|
1532
|
+
reconnectAttempts +
|
|
1533
|
+
")"
|
|
1534
|
+
else " (connectById)")
|
|
1129
1535
|
)
|
|
1130
1536
|
synchronized(connectionLock) {
|
|
1131
|
-
if (isConnected || isConnecting) {
|
|
1537
|
+
if (pairingYieldActive || isConnected || isConnecting) {
|
|
1132
1538
|
return
|
|
1133
1539
|
}
|
|
1134
1540
|
isConnecting = true
|
|
@@ -1136,6 +1542,7 @@ class MentraLive : SGCManager() {
|
|
|
1136
1542
|
stopScan()
|
|
1137
1543
|
emitStopScanEvent()
|
|
1138
1544
|
isReconnecting = false
|
|
1545
|
+
explicitConnectByName = false
|
|
1139
1546
|
connectToDevice(device)
|
|
1140
1547
|
}
|
|
1141
1548
|
}
|
|
@@ -1217,11 +1624,47 @@ class MentraLive : SGCManager() {
|
|
|
1217
1624
|
return false
|
|
1218
1625
|
}
|
|
1219
1626
|
|
|
1627
|
+
private fun beginPairingTiming(reason: String) {
|
|
1628
|
+
pairingTimingStartMs = SystemClock.elapsedRealtime()
|
|
1629
|
+
pairingTimingLastMs = pairingTimingStartMs
|
|
1630
|
+
pairingTimingActive = true
|
|
1631
|
+
Bridge.log("LIVE: PAIRING_TIMING begin reason=$reason sinceStartMs=0 deltaMs=0")
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
private fun markPairingTiming(checkpoint: String, detail: String = "") {
|
|
1635
|
+
if (!pairingTimingActive) {
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
val now = SystemClock.elapsedRealtime()
|
|
1639
|
+
val sinceStart = now - pairingTimingStartMs
|
|
1640
|
+
val delta = now - pairingTimingLastMs
|
|
1641
|
+
pairingTimingLastMs = now
|
|
1642
|
+
val extra = if (detail.isEmpty()) "" else " $detail"
|
|
1643
|
+
Bridge.log(
|
|
1644
|
+
"LIVE: PAIRING_TIMING checkpoint=$checkpoint sinceStartMs=$sinceStart deltaMs=$delta$extra"
|
|
1645
|
+
)
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
private fun endPairingTiming(checkpoint: String, detail: String = "") {
|
|
1649
|
+
markPairingTiming(checkpoint, detail)
|
|
1650
|
+
pairingTimingActive = false
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1220
1653
|
/** Connect to a specific BLE device */
|
|
1221
1654
|
private fun connectToDevice(device: BluetoothDevice?) {
|
|
1222
1655
|
if (device == null) {
|
|
1223
1656
|
return
|
|
1224
1657
|
}
|
|
1658
|
+
if (pairingYieldActive) {
|
|
1659
|
+
Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
|
|
1660
|
+
isConnecting = false
|
|
1661
|
+
isReconnecting = false
|
|
1662
|
+
return
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
beginPairingTiming(
|
|
1666
|
+
"connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
|
|
1667
|
+
)
|
|
1225
1668
|
|
|
1226
1669
|
// Cancel any previous connection timeouts
|
|
1227
1670
|
if (connectionTimeoutRunnable != null) {
|
|
@@ -1321,6 +1764,85 @@ class MentraLive : SGCManager() {
|
|
|
1321
1764
|
// }
|
|
1322
1765
|
// }
|
|
1323
1766
|
|
|
1767
|
+
private fun enterPairingYield(windowMs: Long) {
|
|
1768
|
+
pairingYieldActive = true
|
|
1769
|
+
pairingYieldAwaitingReclaim = false
|
|
1770
|
+
isReconnecting = false
|
|
1771
|
+
isConnecting = false
|
|
1772
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
1773
|
+
if (connectionTimeoutRunnable != null) {
|
|
1774
|
+
connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
|
|
1775
|
+
connectionTimeoutRunnable = null
|
|
1776
|
+
}
|
|
1777
|
+
// Stop scans/reconnect work; keep default_wearable / Classic bond.
|
|
1778
|
+
if (isScanning) {
|
|
1779
|
+
stopScan()
|
|
1780
|
+
emitStopScanEvent()
|
|
1781
|
+
}
|
|
1782
|
+
// Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
|
|
1783
|
+
// Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
|
|
1784
|
+
val yieldingDevice = connectedDevice
|
|
1785
|
+
disconnectClassicProfiles(yieldingDevice)
|
|
1786
|
+
// closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
|
|
1787
|
+
// onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
|
|
1788
|
+
// Mirror a real disconnect for UI + session state, then tear GATT down.
|
|
1789
|
+
isConnected = false
|
|
1790
|
+
connectedDevice = null
|
|
1791
|
+
glassesReady = false
|
|
1792
|
+
glassesSessionId = null
|
|
1793
|
+
readinessCompletedThisBleSession = false
|
|
1794
|
+
glassesReadyReceived = false
|
|
1795
|
+
ctkdInitiatedThisGattSession = false
|
|
1796
|
+
audioConnected = false
|
|
1797
|
+
notificationsEnabled = false
|
|
1798
|
+
processSendQueueRunnable?.let { handler.removeCallbacks(it) }
|
|
1799
|
+
stopReadinessCheckLoop()
|
|
1800
|
+
stopHeartbeat()
|
|
1801
|
+
stopSignalStrengthPolling()
|
|
1802
|
+
stopMicBeat()
|
|
1803
|
+
closeL2capFileChannel()
|
|
1804
|
+
fileProcessingHandler.removeCallbacksAndMessages(null)
|
|
1805
|
+
clearFilePacketBuffer()
|
|
1806
|
+
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
1807
|
+
closeGattQuietly(true)
|
|
1808
|
+
// Stand down for the whole window. Probing GATT during yield races the new
|
|
1809
|
+
// phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
|
|
1810
|
+
val end =
|
|
1811
|
+
Runnable {
|
|
1812
|
+
Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
|
|
1813
|
+
pairingYieldActive = false
|
|
1814
|
+
pairingYieldAwaitingReclaim = true
|
|
1815
|
+
pairingYieldEndRunnable = null
|
|
1816
|
+
if (!isKilled && !isConnected && !isConnecting) {
|
|
1817
|
+
handleReconnection()
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
pairingYieldEndRunnable = end
|
|
1821
|
+
handler.postDelayed(end, windowMs)
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
fun isPairingYieldActive(): Boolean = pairingYieldActive
|
|
1825
|
+
|
|
1826
|
+
private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
|
|
1827
|
+
Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
|
|
1828
|
+
pairingYieldAwaitingReclaim = false
|
|
1829
|
+
pairingYieldActive = false
|
|
1830
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
1831
|
+
pairingYieldEndRunnable = null
|
|
1832
|
+
try {
|
|
1833
|
+
forget()
|
|
1834
|
+
} catch (e: Exception) {
|
|
1835
|
+
Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
|
|
1836
|
+
}
|
|
1837
|
+
DeviceStore.apply("bluetooth", "default_wearable", "")
|
|
1838
|
+
DeviceStore.apply("bluetooth", "device_name", "")
|
|
1839
|
+
DeviceStore.apply("bluetooth", "device_address", "")
|
|
1840
|
+
Bridge.saveSetting("default_wearable", "")
|
|
1841
|
+
Bridge.saveSetting("device_name", "")
|
|
1842
|
+
Bridge.saveSetting("device_address", "")
|
|
1843
|
+
Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1324
1846
|
/** Handle reconnection with exponential backoff */
|
|
1325
1847
|
private fun handleReconnection() {
|
|
1326
1848
|
// Don't attempt reconnection if we've been killed/forgotten
|
|
@@ -1330,6 +1852,20 @@ class MentraLive : SGCManager() {
|
|
|
1330
1852
|
return
|
|
1331
1853
|
}
|
|
1332
1854
|
|
|
1855
|
+
if (manualDiscoveryActive) {
|
|
1856
|
+
Bridge.log(
|
|
1857
|
+
"LIVE: 🔌 RECONNECT ABORTED - Pair Glasses discovery is active"
|
|
1858
|
+
)
|
|
1859
|
+
isReconnecting = false
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if (pairingYieldActive) {
|
|
1864
|
+
Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
|
|
1865
|
+
isReconnecting = false
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1333
1869
|
if (isReconnecting) {
|
|
1334
1870
|
Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
|
|
1335
1871
|
return
|
|
@@ -1401,10 +1937,25 @@ class MentraLive : SGCManager() {
|
|
|
1401
1937
|
"ms)"
|
|
1402
1938
|
)
|
|
1403
1939
|
|
|
1404
|
-
// Schedule reconnection attempt
|
|
1940
|
+
// Schedule reconnection attempt. A later manual scan/connect invalidates this
|
|
1941
|
+
// callback so stale saved identity cannot hijack user-initiated discovery.
|
|
1942
|
+
val generation = reconnectGeneration
|
|
1405
1943
|
handler.postDelayed(
|
|
1406
1944
|
object : Runnable {
|
|
1407
1945
|
override fun run() {
|
|
1946
|
+
if (generation != reconnectGeneration) {
|
|
1947
|
+
Bridge.log(
|
|
1948
|
+
"LIVE: 🔌 RECONNECT ABORTED - superseded by manual discovery"
|
|
1949
|
+
)
|
|
1950
|
+
return
|
|
1951
|
+
}
|
|
1952
|
+
if (pairingYieldActive) {
|
|
1953
|
+
isReconnecting = false
|
|
1954
|
+
Bridge.log(
|
|
1955
|
+
"LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
|
|
1956
|
+
)
|
|
1957
|
+
return
|
|
1958
|
+
}
|
|
1408
1959
|
if (!isConnected && !isConnecting && !isKilled) {
|
|
1409
1960
|
// Prefer saved MAC for direct GATT connect (faster and more reliable
|
|
1410
1961
|
// than scanning).
|
|
@@ -1554,6 +2105,7 @@ class MentraLive : SGCManager() {
|
|
|
1554
2105
|
Bridge.log(
|
|
1555
2106
|
"LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
|
|
1556
2107
|
)
|
|
2108
|
+
markPairingTiming("gatt_connected")
|
|
1557
2109
|
isConnecting = false
|
|
1558
2110
|
isConnected = true
|
|
1559
2111
|
connectedDevice = gatt.device
|
|
@@ -1581,42 +2133,27 @@ class MentraLive : SGCManager() {
|
|
|
1581
2133
|
// connectedDevice.getName());
|
|
1582
2134
|
// }
|
|
1583
2135
|
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
2136
|
+
// Register early so we don't miss bond broadcasts, but defer
|
|
2137
|
+
// createBond / A2DP until BLE GATT setup finishes (see
|
|
2138
|
+
// maybeInitiateCtkdAudioAfterBleReady).
|
|
1586
2139
|
registerBondingReceiver()
|
|
1587
|
-
bondingRetryCount = 0
|
|
2140
|
+
bondingRetryCount = 0
|
|
2141
|
+
ctkdInitiatedThisGattSession = false
|
|
1588
2142
|
Bridge.log(
|
|
1589
|
-
"LIVE: CTKD: BLE
|
|
2143
|
+
"LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
|
|
2144
|
+
)
|
|
2145
|
+
markPairingTiming(
|
|
2146
|
+
"ctkd_deferred",
|
|
2147
|
+
"bondState=${connectedDevice!!.bondState}"
|
|
1590
2148
|
)
|
|
1591
|
-
|
|
1592
|
-
// Check if device is already bonded before attempting to create bond
|
|
1593
|
-
if (connectedDevice!!.bondState == BluetoothDevice.BOND_BONDED) {
|
|
1594
|
-
Bridge.log(
|
|
1595
|
-
"LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
|
|
1596
|
-
)
|
|
1597
|
-
// Device is bonded but we need to explicitly connect the A2DP audio
|
|
1598
|
-
// profile
|
|
1599
|
-
// Just being bonded doesn't mean the audio profile is connected
|
|
1600
|
-
connectA2dpProfile(connectedDevice!!)
|
|
1601
|
-
// Note: audioConnected will be set to true once A2DP profile
|
|
1602
|
-
// connects
|
|
1603
|
-
} else {
|
|
1604
|
-
createBond(connectedDevice!!)
|
|
1605
|
-
}
|
|
1606
2149
|
|
|
1607
2150
|
// Discover services
|
|
1608
2151
|
gatt.discoverServices()
|
|
1609
2152
|
|
|
1610
|
-
//
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
isReconnecting = false
|
|
1614
|
-
Log.i(
|
|
1615
|
-
TAG,
|
|
1616
|
-
"🔌 ✅ Reconnection counter reset (was at " +
|
|
1617
|
-
previousAttempts +
|
|
1618
|
-
" attempts)"
|
|
1619
|
-
)
|
|
2153
|
+
// Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
|
|
2154
|
+
// followed by status 19 was zeroing the counter every ~1s and
|
|
2155
|
+
// preventing exponential backoff. Reset at ble_chars_ready instead.
|
|
2156
|
+
isReconnecting = false
|
|
1620
2157
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
1621
2158
|
Log.w(
|
|
1622
2159
|
TAG,
|
|
@@ -1625,6 +2162,10 @@ class MentraLive : SGCManager() {
|
|
|
1625
2162
|
Bridge.log(
|
|
1626
2163
|
"LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
|
|
1627
2164
|
)
|
|
2165
|
+
endPairingTiming(
|
|
2166
|
+
"gatt_disconnected",
|
|
2167
|
+
"queueSize=${sendQueue.size}"
|
|
2168
|
+
)
|
|
1628
2169
|
isConnected = false
|
|
1629
2170
|
isConnecting = false
|
|
1630
2171
|
|
|
@@ -1635,6 +2176,7 @@ class MentraLive : SGCManager() {
|
|
|
1635
2176
|
|
|
1636
2177
|
// Reset audio pairing flags
|
|
1637
2178
|
glassesReadyReceived = false
|
|
2179
|
+
ctkdInitiatedThisGattSession = false
|
|
1638
2180
|
audioConnected = false
|
|
1639
2181
|
|
|
1640
2182
|
notificationsEnabled = false
|
|
@@ -1693,6 +2235,10 @@ class MentraLive : SGCManager() {
|
|
|
1693
2235
|
status +
|
|
1694
2236
|
") - Will retry reconnection"
|
|
1695
2237
|
)
|
|
2238
|
+
endPairingTiming(
|
|
2239
|
+
"gatt_error",
|
|
2240
|
+
"status=$status queueSize=${sendQueue.size}"
|
|
2241
|
+
)
|
|
1696
2242
|
isConnected = false
|
|
1697
2243
|
isConnecting = false
|
|
1698
2244
|
glassesReady = false
|
|
@@ -1700,6 +2246,7 @@ class MentraLive : SGCManager() {
|
|
|
1700
2246
|
readinessCompletedThisBleSession = false
|
|
1701
2247
|
glassesReadyReceived = false
|
|
1702
2248
|
audioConnected = false
|
|
2249
|
+
ctkdInitiatedThisGattSession = false
|
|
1703
2250
|
|
|
1704
2251
|
notificationsEnabled = false
|
|
1705
2252
|
|
|
@@ -1723,6 +2270,19 @@ class MentraLive : SGCManager() {
|
|
|
1723
2270
|
// Clean up resources
|
|
1724
2271
|
closeGattQuietly(false)
|
|
1725
2272
|
|
|
2273
|
+
// Auth/not-owner after pairing yield — not a generic RF blip.
|
|
2274
|
+
// GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
|
|
2275
|
+
// not wipe the saved owner.
|
|
2276
|
+
if (pairingYieldAwaitingReclaim &&
|
|
2277
|
+
(status == 5 ||
|
|
2278
|
+
status == 15 ||
|
|
2279
|
+
status == 0x05 ||
|
|
2280
|
+
status == 0x0F)
|
|
2281
|
+
) {
|
|
2282
|
+
clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
|
|
2283
|
+
return
|
|
2284
|
+
}
|
|
2285
|
+
|
|
1726
2286
|
// Attempt reconnection if not killed
|
|
1727
2287
|
if (!isKilled) {
|
|
1728
2288
|
Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
|
|
@@ -1773,24 +2333,47 @@ class MentraLive : SGCManager() {
|
|
|
1773
2333
|
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
|
|
1774
2334
|
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
|
|
1775
2335
|
|
|
1776
|
-
//
|
|
2336
|
+
// LC3 audio is optional. Older firmware can still run the core session
|
|
2337
|
+
// and receive an OTA without these characteristics.
|
|
1777
2338
|
lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
|
|
1778
2339
|
lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
|
|
1779
2340
|
|
|
1780
|
-
//
|
|
2341
|
+
// Match iOS: only the core transport pair is required for readiness.
|
|
1781
2342
|
val hasRequiredCharacteristics =
|
|
1782
|
-
(
|
|
1783
|
-
|
|
1784
|
-
|
|
2343
|
+
hasRequiredMentraLiveCoreCharacteristics(
|
|
2344
|
+
hasRxCharacteristic = rxCharacteristic != null,
|
|
2345
|
+
hasTxCharacteristic = txCharacteristic != null,
|
|
2346
|
+
)
|
|
2347
|
+
val hasLc3AudioCharacteristics =
|
|
2348
|
+
lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
|
|
1785
2349
|
|
|
1786
2350
|
if (hasRequiredCharacteristics) {
|
|
2351
|
+
if (!hasLc3AudioCharacteristics) {
|
|
2352
|
+
Bridge.log(
|
|
2353
|
+
"LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
|
|
2354
|
+
)
|
|
2355
|
+
}
|
|
1787
2356
|
// BLE connection established, but we still need to wait for glasses
|
|
1788
2357
|
// SOC
|
|
1789
2358
|
Bridge.log(
|
|
1790
|
-
"LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX
|
|
2359
|
+
"LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
|
|
2360
|
+
hasLc3AudioCharacteristics +
|
|
2361
|
+
")"
|
|
1791
2362
|
)
|
|
2363
|
+
markPairingTiming("ble_chars_ready")
|
|
1792
2364
|
Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
|
|
1793
2365
|
|
|
2366
|
+
// GATT stack reached real readiness — safe to clear reconnect
|
|
2367
|
+
// backoff.
|
|
2368
|
+
val previousAttempts = reconnectAttempts
|
|
2369
|
+
reconnectAttempts = 0
|
|
2370
|
+
Log.i(
|
|
2371
|
+
TAG,
|
|
2372
|
+
"🔌 ✅ Reconnection counter reset at ble_chars_ready (was at " +
|
|
2373
|
+
previousAttempts +
|
|
2374
|
+
" attempts)"
|
|
2375
|
+
)
|
|
2376
|
+
|
|
1794
2377
|
// Don't set connected=true here - wait for SOC to be ready
|
|
1795
2378
|
// (fullyBooted=true)
|
|
1796
2379
|
// DeviceStore handles connected state based on fullyBooted
|
|
@@ -1831,13 +2414,6 @@ class MentraLive : SGCManager() {
|
|
|
1831
2414
|
if (txCharacteristic == null) {
|
|
1832
2415
|
Log.e(TAG, "TX characteristic (peripheral's RX) not found")
|
|
1833
2416
|
}
|
|
1834
|
-
// Log LC3 characteristic errors
|
|
1835
|
-
if (lc3ReadCharacteristic == null) {
|
|
1836
|
-
Log.e(TAG, "LC3_READ characteristic not found")
|
|
1837
|
-
}
|
|
1838
|
-
if (lc3WriteCharacteristic == null) {
|
|
1839
|
-
Log.e(TAG, "LC3_WRITE characteristic not found")
|
|
1840
|
-
}
|
|
1841
2417
|
gatt.disconnect()
|
|
1842
2418
|
}
|
|
1843
2419
|
} else {
|
|
@@ -1944,6 +2520,10 @@ class MentraLive : SGCManager() {
|
|
|
1944
2520
|
gatt: BluetoothGatt,
|
|
1945
2521
|
characteristic: BluetoothGattCharacteristic
|
|
1946
2522
|
) {
|
|
2523
|
+
if (!isCurrentGattCallback(gatt)) {
|
|
2524
|
+
return
|
|
2525
|
+
}
|
|
2526
|
+
|
|
1947
2527
|
// Get thread ID for tracking thread issues
|
|
1948
2528
|
val threadId = Thread.currentThread().id
|
|
1949
2529
|
val uuid = characteristic.uuid
|
|
@@ -1977,20 +2557,6 @@ class MentraLive : SGCManager() {
|
|
|
1977
2557
|
|
|
1978
2558
|
if (isRxCharacteristic) {
|
|
1979
2559
|
Bridge.log("LIVE: Received data on RX characteristic")
|
|
1980
|
-
// #region agent log [810da2] Hypothesis A+C: capture data.length vs
|
|
1981
|
-
// negotiated MTU
|
|
1982
|
-
Bridge.log(
|
|
1983
|
-
"LIVE: [DEBUG-810da2-HypAC] RX dataLen=" +
|
|
1984
|
-
data.size +
|
|
1985
|
-
" mtu=" +
|
|
1986
|
-
currentMtu +
|
|
1987
|
-
" firstByte=0x" +
|
|
1988
|
-
String.format("%02X", data[0]) +
|
|
1989
|
-
" second=0x" +
|
|
1990
|
-
(if (data.size > 1) String.format("%02X", data[1])
|
|
1991
|
-
else "??")
|
|
1992
|
-
)
|
|
1993
|
-
// #endregion
|
|
1994
2560
|
} else if (isTxCharacteristic) {
|
|
1995
2561
|
Bridge.log("LIVE: Received data on TX characteristic")
|
|
1996
2562
|
} else if (isLc3ReadCharacteristic) {
|
|
@@ -2095,6 +2661,9 @@ class MentraLive : SGCManager() {
|
|
|
2095
2661
|
startSignalStrengthPolling()
|
|
2096
2662
|
handler.post(processSendQueueRunnable!!)
|
|
2097
2663
|
startReadinessCheckLoop()
|
|
2664
|
+
// Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
|
|
2665
|
+
// collide during service discovery / descriptor writes.
|
|
2666
|
+
maybeInitiateCtkdAudioAfterBleReady()
|
|
2098
2667
|
return
|
|
2099
2668
|
}
|
|
2100
2669
|
|
|
@@ -3068,25 +3637,6 @@ class MentraLive : SGCManager() {
|
|
|
3068
3637
|
processJsonMessage(expanded)
|
|
3069
3638
|
} else {
|
|
3070
3639
|
Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
|
|
3071
|
-
// #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
|
|
3072
|
-
// data length
|
|
3073
|
-
val declaredPayloadLen =
|
|
3074
|
-
if (data.size >= 5)
|
|
3075
|
-
(((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF))
|
|
3076
|
-
else -1
|
|
3077
|
-
Bridge.log(
|
|
3078
|
-
"LIVE: [DEBUG-810da2-HypAB] K900 PARSE FAILED thread=" +
|
|
3079
|
-
threadId +
|
|
3080
|
-
" dataLen=" +
|
|
3081
|
-
data.size +
|
|
3082
|
-
" mtu=" +
|
|
3083
|
-
currentMtu +
|
|
3084
|
-
" declaredPayloadLen=" +
|
|
3085
|
-
declaredPayloadLen +
|
|
3086
|
-
" expectedTotal=" +
|
|
3087
|
-
(declaredPayloadLen + 7)
|
|
3088
|
-
)
|
|
3089
|
-
// #endregion
|
|
3090
3640
|
}
|
|
3091
3641
|
|
|
3092
3642
|
return // Exit after processing K900 protocol format
|
|
@@ -3260,6 +3810,27 @@ class MentraLive : SGCManager() {
|
|
|
3260
3810
|
"pong" ->
|
|
3261
3811
|
// Process heartbeat pong response
|
|
3262
3812
|
Bridge.log("LIVE: Received pong response - connection healthy")
|
|
3813
|
+
"entering_pairing_mode" -> {
|
|
3814
|
+
val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
|
|
3815
|
+
Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
|
|
3816
|
+
enterPairingYield(windowMs)
|
|
3817
|
+
val body = HashMap<String, Any>()
|
|
3818
|
+
body["window_ms"] = windowMs
|
|
3819
|
+
body["reason"] = json.optString("reason", "user_gesture")
|
|
3820
|
+
if (json.has("txn")) body["txn"] = json.optInt("txn")
|
|
3821
|
+
Bridge.sendTypedMessage("entering_pairing_mode", body)
|
|
3822
|
+
}
|
|
3823
|
+
"pairing_info" ->
|
|
3824
|
+
Bridge.sendPairingInfo(
|
|
3825
|
+
json.optBoolean("had_previous_bond", false),
|
|
3826
|
+
jsonNullableString(json, "transfer_id"),
|
|
3827
|
+
jsonNullableString(json, "pairing_code"),
|
|
3828
|
+
json.optBoolean("classic_bond_ready", false),
|
|
3829
|
+
// Legacy firmware that omits this field is not secure-capable.
|
|
3830
|
+
json.optBoolean("secure_pairing_capable", false),
|
|
3831
|
+
json.optInt("protocol_version", 1),
|
|
3832
|
+
jsonNullableString(json, "binding"),
|
|
3833
|
+
)
|
|
3263
3834
|
"imu_response",
|
|
3264
3835
|
"imu_stream_response",
|
|
3265
3836
|
"imu_gesture_response",
|
|
@@ -3648,7 +4219,19 @@ class MentraLive : SGCManager() {
|
|
|
3648
4219
|
}
|
|
3649
4220
|
"glasses_ready" -> {
|
|
3650
4221
|
// Glasses SOC has booted and is ready for communication
|
|
4222
|
+
if (pairingYieldActive && !isConnected) {
|
|
4223
|
+
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
|
|
4224
|
+
return
|
|
4225
|
+
}
|
|
3651
4226
|
Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
|
|
4227
|
+
pairingYieldAwaitingReclaim = false
|
|
4228
|
+
pairingYieldActive = false
|
|
4229
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
4230
|
+
pairingYieldEndRunnable = null
|
|
4231
|
+
markPairingTiming(
|
|
4232
|
+
"glasses_ready",
|
|
4233
|
+
"audioConnected=$audioConnected queueSize=${sendQueue.size}"
|
|
4234
|
+
)
|
|
3652
4235
|
|
|
3653
4236
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
3654
4237
|
// onTransportReset() before sending it, so their side is back on legacy
|
|
@@ -3667,9 +4250,9 @@ class MentraLive : SGCManager() {
|
|
|
3667
4250
|
// Set the ready flag to stop any future readiness checks
|
|
3668
4251
|
glassesReady = true
|
|
3669
4252
|
glassesReadyReceived = true
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
//
|
|
4253
|
+
// Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
|
|
4254
|
+
// fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
|
|
4255
|
+
// for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
|
|
3673
4256
|
|
|
3674
4257
|
// Stop the readiness check loop since we got confirmation
|
|
3675
4258
|
stopReadinessCheckLoop()
|
|
@@ -3789,19 +4372,21 @@ class MentraLive : SGCManager() {
|
|
|
3789
4372
|
Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
|
|
3790
4373
|
}
|
|
3791
4374
|
|
|
3792
|
-
//
|
|
3793
|
-
//
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
4375
|
+
// Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
|
|
4376
|
+
// Do not wait for CTKD here — iOS already marks ready on glasses_ready alone.
|
|
4377
|
+
endPairingTiming(
|
|
4378
|
+
"fully_connected",
|
|
4379
|
+
"via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
|
|
4380
|
+
)
|
|
4381
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
4382
|
+
updateConnectionState(ConnTypes.CONNECTED)
|
|
4383
|
+
if (!audioConnected) {
|
|
3797
4384
|
Bridge.log(
|
|
3798
|
-
"LIVE: Audio:
|
|
4385
|
+
"LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
|
|
3799
4386
|
)
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
Bridge.log(
|
|
3804
|
-
"LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"
|
|
4387
|
+
markPairingTiming(
|
|
4388
|
+
"waiting_ctkd_bond",
|
|
4389
|
+
"queueSize=${sendQueue.size}"
|
|
3805
4390
|
)
|
|
3806
4391
|
}
|
|
3807
4392
|
}
|
|
@@ -4115,6 +4700,9 @@ class MentraLive : SGCManager() {
|
|
|
4115
4700
|
(fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4116
4701
|
DeviceStore.apply("glasses", "bluetoothMacAddress", it)
|
|
4117
4702
|
}
|
|
4703
|
+
(fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4704
|
+
DeviceStore.apply("glasses", "wifiMacAddress", it)
|
|
4705
|
+
}
|
|
4118
4706
|
(fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4119
4707
|
DeviceStore.apply("glasses", "serialNumber", it)
|
|
4120
4708
|
}
|
|
@@ -4124,6 +4712,12 @@ class MentraLive : SGCManager() {
|
|
|
4124
4712
|
DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
|
|
4125
4713
|
}
|
|
4126
4714
|
}
|
|
4715
|
+
if (fields.containsKey("hotspot_ota_version")) {
|
|
4716
|
+
val version = fields["hotspot_ota_version"]
|
|
4717
|
+
if (version is Number) {
|
|
4718
|
+
DeviceStore.apply("glasses", "hotspotOtaVersion", version.toInt())
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4127
4721
|
|
|
4128
4722
|
// Negotiate wire caps from ANY version_info chunk, not only the one
|
|
4129
4723
|
// carrying build_number: the glasses put wire_caps in version_info_3
|
|
@@ -4480,6 +5074,10 @@ class MentraLive : SGCManager() {
|
|
|
4480
5074
|
}
|
|
4481
5075
|
if (ready == 1) {
|
|
4482
5076
|
Bridge.log("LIVE: K900 SOC ready")
|
|
5077
|
+
markPairingTiming(
|
|
5078
|
+
"soc_ready",
|
|
5079
|
+
"glassesReady=$glassesReady queueSize=${sendQueue.size}"
|
|
5080
|
+
)
|
|
4483
5081
|
// Only send phone_ready if we haven't already established connection
|
|
4484
5082
|
// This prevents re-initialization on every heartbeat after initial
|
|
4485
5083
|
// connection
|
|
@@ -5187,6 +5785,8 @@ class MentraLive : SGCManager() {
|
|
|
5187
5785
|
|
|
5188
5786
|
/** Start the micbeat mechanism - periodically enable custom audio TX */
|
|
5189
5787
|
private fun startMicBeat() {
|
|
5788
|
+
// A resumed custom-audio stream starts a new sequence domain.
|
|
5789
|
+
DeviceManager.getInstance().resetMicSequenceBaseline()
|
|
5190
5790
|
micOnCount++
|
|
5191
5791
|
Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
|
|
5192
5792
|
micBeatCount = 0
|
|
@@ -5328,8 +5928,16 @@ class MentraLive : SGCManager() {
|
|
|
5328
5928
|
override fun findCompatibleDevices() {
|
|
5329
5929
|
Bridge.log("LIVE: Finding compatible Mentra Live glasses")
|
|
5330
5930
|
|
|
5331
|
-
//
|
|
5931
|
+
// A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
|
|
5932
|
+
// and replace any active fast reconnect scan with discovery mode.
|
|
5933
|
+
reconnectGeneration++
|
|
5934
|
+
if (isScanning) {
|
|
5935
|
+
stopScan()
|
|
5936
|
+
}
|
|
5937
|
+
manualDiscoveryActive = true
|
|
5332
5938
|
isReconnecting = false
|
|
5939
|
+
// Discovery-only: do not treat leftover savedDeviceName as a connect target.
|
|
5940
|
+
explicitConnectByName = false
|
|
5333
5941
|
|
|
5334
5942
|
if (bluetoothAdapter == null) {
|
|
5335
5943
|
Log.e(TAG, "Bluetooth not available")
|
|
@@ -5341,50 +5949,123 @@ class MentraLive : SGCManager() {
|
|
|
5341
5949
|
return
|
|
5342
5950
|
}
|
|
5343
5951
|
|
|
5952
|
+
// Stale Classic ACL keeps Mentra Live from advertising — drop A2DP/HFP (keep bond)
|
|
5953
|
+
// so a pairing/discovery scan can see the unit again. Keep the active session's
|
|
5954
|
+
// audio; tear down other bonded Lives so a second unit can advertise.
|
|
5955
|
+
releaseStaleClassicForDiscovery()
|
|
5956
|
+
|
|
5344
5957
|
// Start scanning for BLE devices
|
|
5345
5958
|
startScan()
|
|
5346
5959
|
}
|
|
5347
5960
|
|
|
5961
|
+
private fun releaseStaleClassicForDiscovery() {
|
|
5962
|
+
val adapter = bluetoothAdapter ?: return
|
|
5963
|
+
val bonded =
|
|
5964
|
+
try {
|
|
5965
|
+
adapter.bondedDevices?.toList().orEmpty()
|
|
5966
|
+
} catch (e: Exception) {
|
|
5967
|
+
Bridge.log("LIVE: Classic: discovery release — cannot list bonded: " + e.message)
|
|
5968
|
+
return
|
|
5969
|
+
}
|
|
5970
|
+
val mentraBonded =
|
|
5971
|
+
bonded.filter { isMentraLiveBluetoothName(safeDeviceName(it)) }
|
|
5972
|
+
if (mentraBonded.isEmpty()) {
|
|
5973
|
+
return
|
|
5974
|
+
}
|
|
5975
|
+
val currentAddress = connectedDevice?.address
|
|
5976
|
+
for (device in mentraBonded) {
|
|
5977
|
+
if (currentAddress != null && device.address.equals(currentAddress, ignoreCase = true)) {
|
|
5978
|
+
continue
|
|
5979
|
+
}
|
|
5980
|
+
Bridge.log(
|
|
5981
|
+
"LIVE: Classic: releasing stale A2DP/HFP before discovery for " +
|
|
5982
|
+
safeDeviceName(device) +
|
|
5983
|
+
" (" +
|
|
5984
|
+
device.address +
|
|
5985
|
+
")"
|
|
5986
|
+
)
|
|
5987
|
+
disconnectClassicProfiles(device)
|
|
5988
|
+
}
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5348
5991
|
override fun connectById(id: String) {
|
|
5992
|
+
if (pairingYieldActive) {
|
|
5993
|
+
Bridge.log("LIVE: connectById blocked — pairing yield active")
|
|
5994
|
+
return
|
|
5995
|
+
}
|
|
5996
|
+
reconnectGeneration++
|
|
5997
|
+
manualDiscoveryActive = false
|
|
5998
|
+
if (isScanning) {
|
|
5999
|
+
stopScan()
|
|
6000
|
+
}
|
|
6001
|
+
isReconnecting = false
|
|
5349
6002
|
Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
|
|
5350
6003
|
savedDeviceName = id
|
|
5351
|
-
//
|
|
5352
|
-
//
|
|
5353
|
-
|
|
5354
|
-
// Log.i(TAG, "🔌 💾 Saved device name for future reconnection: " +
|
|
5355
|
-
// connectedDevice.getName());
|
|
5356
|
-
// Bridge.log("LIVE: Saved device name for future reconnection: " +
|
|
5357
|
-
// connectedDevice.getName());
|
|
6004
|
+
// Without this, connectToSmartGlasses() falls back to a name scan with
|
|
6005
|
+
// isReconnecting=false and never GATT-connects (loading screen hangs).
|
|
6006
|
+
explicitConnectByName = true
|
|
5358
6007
|
connectToSmartGlasses()
|
|
5359
6008
|
}
|
|
5360
6009
|
|
|
6010
|
+
/**
|
|
6011
|
+
* True while Android is showing (or about to show) the system pairing dialog for
|
|
6012
|
+
* CTKD Classic bonding. Calling [removeBond] / tearing down the SGC here aborts that
|
|
6013
|
+
* dialog and forces a full re-pair.
|
|
6014
|
+
*/
|
|
6015
|
+
fun isCtkdBondingInProgress(): Boolean {
|
|
6016
|
+
val device = connectedDevice ?: return false
|
|
6017
|
+
return device.bondState == BluetoothDevice.BOND_BONDING
|
|
6018
|
+
}
|
|
6019
|
+
|
|
5361
6020
|
override fun forget() {
|
|
5362
6021
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
5363
6022
|
|
|
5364
|
-
// Clear saved device name
|
|
5365
|
-
//
|
|
5366
|
-
|
|
5367
|
-
// Bridge.log("LIVE: Cleared saved device name");
|
|
6023
|
+
// Clear saved device name so a leftover name can't bypass the pairing-mode
|
|
6024
|
+
// discovery filter for this unit on a subsequent rescan (parity with iOS forget()).
|
|
6025
|
+
savedDeviceName = ""
|
|
5368
6026
|
|
|
5369
6027
|
// Reset reconnection state
|
|
5370
6028
|
reconnectAttempts = 0
|
|
5371
6029
|
isReconnecting = false
|
|
6030
|
+
manualDiscoveryActive = false
|
|
6031
|
+
explicitConnectByName = false
|
|
5372
6032
|
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
if (connectedDevice != null) {
|
|
5376
|
-
Bridge.log("LIVE: CTKD: Removing BT bond on explicit unpair")
|
|
5377
|
-
removeBond(connectedDevice!!)
|
|
5378
|
-
}
|
|
5379
|
-
|
|
6033
|
+
val ctx = context ?: Bridge.getContext()
|
|
6034
|
+
val bondedAddress = connectedDevice?.address
|
|
5380
6035
|
if (isScanning) {
|
|
5381
6036
|
stopScan()
|
|
5382
6037
|
emitStopScanEvent()
|
|
5383
6038
|
}
|
|
5384
|
-
|
|
6039
|
+
// Tell glasses to drop Mentra owner before tearing the link. removeBond
|
|
6040
|
+
// alone leaves glasses NV owned, so the next pair hits OWNER_ONLY.
|
|
6041
|
+
try {
|
|
6042
|
+
val unpair = JSONObject()
|
|
6043
|
+
unpair.put("type", "unpair")
|
|
6044
|
+
sendJsonWithoutAck(unpair, true)
|
|
6045
|
+
} catch (e: JSONException) {
|
|
6046
|
+
Log.e(TAG, "Error creating unpair command", e)
|
|
6047
|
+
}
|
|
6048
|
+
// Give the unpair write a beat to leave GATT before destroy() cancels the queue.
|
|
6049
|
+
unpairFlushRunnable?.let { handler.removeCallbacks(it) }
|
|
6050
|
+
unpairFlushPending = true
|
|
6051
|
+
val flush =
|
|
6052
|
+
Runnable {
|
|
6053
|
+
unpairFlushPending = false
|
|
6054
|
+
unpairFlushRunnable = null
|
|
6055
|
+
// Drop GATT first. removeBond while the BLE link is still encrypted often
|
|
6056
|
+
// returns true on Samsung but the OS Bluetooth UI keeps the dual-mode pair.
|
|
6057
|
+
disconnect()
|
|
6058
|
+
unbondBondedMentraLiveDevices(ctx, bondedAddress)
|
|
6059
|
+
}
|
|
6060
|
+
unpairFlushRunnable = flush
|
|
6061
|
+
handler.postDelayed(flush, UNPAIR_FLUSH_MS)
|
|
5385
6062
|
}
|
|
5386
6063
|
|
|
5387
6064
|
override fun disconnect() {
|
|
6065
|
+
if (unpairFlushPending) {
|
|
6066
|
+
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
6067
|
+
return
|
|
6068
|
+
}
|
|
5388
6069
|
Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
|
|
5389
6070
|
destroy()
|
|
5390
6071
|
}
|
|
@@ -5422,6 +6103,10 @@ class MentraLive : SGCManager() {
|
|
|
5422
6103
|
}
|
|
5423
6104
|
|
|
5424
6105
|
fun connectToSmartGlasses() {
|
|
6106
|
+
if (pairingYieldActive) {
|
|
6107
|
+
Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
|
|
6108
|
+
return
|
|
6109
|
+
}
|
|
5425
6110
|
Bridge.log("LIVE: Connecting to Mentra Live glasses")
|
|
5426
6111
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
5427
6112
|
|
|
@@ -5450,7 +6135,13 @@ class MentraLive : SGCManager() {
|
|
|
5450
6135
|
// var context = Bridge.getContext();
|
|
5451
6136
|
// SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
5452
6137
|
// String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
|
|
5453
|
-
val
|
|
6138
|
+
val pendingAddress =
|
|
6139
|
+
(DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
|
|
6140
|
+
it.isNotEmpty()
|
|
6141
|
+
}
|
|
6142
|
+
val lastDeviceAddress =
|
|
6143
|
+
pendingAddress
|
|
6144
|
+
?: (DeviceStore.get("bluetooth", "device_address") as String?)
|
|
5454
6145
|
|
|
5455
6146
|
if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
|
|
5456
6147
|
// Connect to last known device if available
|
|
@@ -5461,6 +6152,7 @@ class MentraLive : SGCManager() {
|
|
|
5461
6152
|
Bridge.log(
|
|
5462
6153
|
"LIVE: Found saved device, connecting directly: " + lastDeviceAddress
|
|
5463
6154
|
)
|
|
6155
|
+
prepareClassicBeforeBleConnect(device)
|
|
5464
6156
|
connectToDevice(device)
|
|
5465
6157
|
} else {
|
|
5466
6158
|
Bridge.log(
|
|
@@ -5476,10 +6168,60 @@ class MentraLive : SGCManager() {
|
|
|
5476
6168
|
startScan() // Fallback to scanning
|
|
5477
6169
|
}
|
|
5478
6170
|
} else {
|
|
5479
|
-
//
|
|
5480
|
-
|
|
5481
|
-
|
|
6171
|
+
// Pairing may only have a name (or address wasn't stashed). Prefer a bonded
|
|
6172
|
+
// device match over scan — Classic-connected glasses often stop advertising.
|
|
6173
|
+
val bonded = findBondedDeviceByName(savedDeviceName)
|
|
6174
|
+
if (bonded != null) {
|
|
6175
|
+
Bridge.log(
|
|
6176
|
+
"LIVE: No saved address — using bonded device " +
|
|
6177
|
+
safeDeviceName(bonded) +
|
|
6178
|
+
" (" +
|
|
6179
|
+
bonded.address +
|
|
6180
|
+
")"
|
|
6181
|
+
)
|
|
6182
|
+
prepareClassicBeforeBleConnect(bonded)
|
|
6183
|
+
connectToDevice(bonded)
|
|
6184
|
+
} else {
|
|
6185
|
+
Bridge.log("LIVE: No last known device, starting scan")
|
|
6186
|
+
startScan()
|
|
6187
|
+
}
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
|
|
6191
|
+
private fun findBondedDeviceByName(name: String): BluetoothDevice? {
|
|
6192
|
+
if (name.isEmpty() || bluetoothAdapter == null) {
|
|
6193
|
+
return null
|
|
5482
6194
|
}
|
|
6195
|
+
return try {
|
|
6196
|
+
bluetoothAdapter!!.bondedDevices?.firstOrNull { safeDeviceName(it) == name }
|
|
6197
|
+
} catch (e: SecurityException) {
|
|
6198
|
+
Bridge.log("LIVE: Unable to read bonded devices: " + e.message)
|
|
6199
|
+
null
|
|
6200
|
+
} catch (e: Exception) {
|
|
6201
|
+
Bridge.log("LIVE: Error reading bonded devices: " + e.message)
|
|
6202
|
+
null
|
|
6203
|
+
}
|
|
6204
|
+
}
|
|
6205
|
+
|
|
6206
|
+
/**
|
|
6207
|
+
* Fresh pairing / discovery may need Classic down so glasses leave a stuck ACL.
|
|
6208
|
+
* Owner reconnect must NOT tear Classic first: BES BLE-PAIR-GUARD historically
|
|
6209
|
+
* admitted Mentra App RPAs only while Classic owner ACL was up — tearing it
|
|
6210
|
+
* caused GATT status=19 reject loops after Mentra App disconnect.
|
|
6211
|
+
*/
|
|
6212
|
+
private fun prepareClassicBeforeBleConnect(device: BluetoothDevice) {
|
|
6213
|
+
if (device.bondState != BluetoothDevice.BOND_BONDED) {
|
|
6214
|
+
return
|
|
6215
|
+
}
|
|
6216
|
+
// Owner reconnect and connectById both go through connectToSmartGlasses(),
|
|
6217
|
+
// which clears isReconnecting first. Leave Classic up: tearing A2DP/HFP
|
|
6218
|
+
// here historically caused GATT status=19 reject loops. Discovery teardown
|
|
6219
|
+
// lives in releaseStaleClassicForDiscovery() for pairing scans only.
|
|
6220
|
+
Bridge.log(
|
|
6221
|
+
"LIVE: Classic: bonded target — leave A2DP/HFP up for BLE admit (" +
|
|
6222
|
+
safeDeviceName(device) +
|
|
6223
|
+
")"
|
|
6224
|
+
)
|
|
5483
6225
|
}
|
|
5484
6226
|
|
|
5485
6227
|
override fun setMicEnabled(enabled: Boolean) {
|
|
@@ -5925,41 +6667,67 @@ class MentraLive : SGCManager() {
|
|
|
5925
6667
|
Bridge.log(
|
|
5926
6668
|
"LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
|
|
5927
6669
|
)
|
|
6670
|
+
markPairingTiming(
|
|
6671
|
+
"ctkd_bonded",
|
|
6672
|
+
"glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
|
|
6673
|
+
)
|
|
5928
6674
|
if (isKilled) {
|
|
5929
6675
|
Bridge.log(
|
|
5930
6676
|
"LIVE: CTKD: Ignoring bond complete — SGC destroyed"
|
|
5931
6677
|
)
|
|
5932
6678
|
return@run
|
|
5933
6679
|
}
|
|
5934
|
-
isBtClassicConnected = true
|
|
5935
|
-
audioConnected = true
|
|
5936
6680
|
bondingRetryCount =
|
|
5937
6681
|
0 // Reset retry counter on success
|
|
5938
|
-
//
|
|
5939
|
-
//
|
|
5940
|
-
|
|
5941
|
-
//
|
|
5942
|
-
//
|
|
6682
|
+
// Bond != audio profile. Explicitly open A2DP
|
|
6683
|
+
// after createBond() completes (already-bonded
|
|
6684
|
+
// path does this at GATT connect).
|
|
6685
|
+
// markAudioConnected / sendAudioConnected fire
|
|
6686
|
+
// when the A2DP path completes.
|
|
6687
|
+
connectA2dpProfile(device)
|
|
6688
|
+
|
|
6689
|
+
// Safety net: if glasses_ready somehow missed
|
|
6690
|
+
// setting fullyBooted, catch up here.
|
|
5943
6691
|
if (glassesReadyReceived) {
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
6692
|
+
val alreadyBooted =
|
|
6693
|
+
DeviceStore.store.get(
|
|
6694
|
+
"glasses",
|
|
6695
|
+
"fullyBooted"
|
|
6696
|
+
) as? Boolean
|
|
6697
|
+
?: false
|
|
6698
|
+
if (!alreadyBooted) {
|
|
6699
|
+
Bridge.log(
|
|
6700
|
+
"LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
|
|
6701
|
+
)
|
|
6702
|
+
endPairingTiming(
|
|
6703
|
+
"fully_connected",
|
|
6704
|
+
"via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
|
|
6705
|
+
)
|
|
6706
|
+
DeviceStore.apply(
|
|
6707
|
+
"glasses",
|
|
6708
|
+
"fullyBooted",
|
|
6709
|
+
true
|
|
6710
|
+
)
|
|
6711
|
+
updateConnectionState(ConnTypes.CONNECTED)
|
|
6712
|
+
} else {
|
|
6713
|
+
Bridge.log(
|
|
6714
|
+
"LIVE: Audio: CTKD bonded after pairing UI already advanced"
|
|
6715
|
+
)
|
|
6716
|
+
markPairingTiming(
|
|
6717
|
+
"ctkd_bonded_after_fully_connected",
|
|
6718
|
+
"queueSize=${sendQueue.size}"
|
|
6719
|
+
)
|
|
6720
|
+
}
|
|
5953
6721
|
}
|
|
5954
|
-
|
|
5955
|
-
// Send audio connected event for platform parity
|
|
5956
|
-
// with iOS
|
|
5957
|
-
Bridge.sendAudioConnected(device.name)
|
|
5958
6722
|
}
|
|
5959
6723
|
BluetoothDevice.BOND_NONE -> {
|
|
5960
6724
|
Bridge.log(
|
|
5961
6725
|
"LIVE: CTKD: ❌ Bonding failed or removed for device"
|
|
5962
6726
|
)
|
|
6727
|
+
markPairingTiming(
|
|
6728
|
+
"ctkd_bond_none",
|
|
6729
|
+
"prev=$previousBondState queueSize=${sendQueue.size}"
|
|
6730
|
+
)
|
|
5963
6731
|
isBtClassicConnected = false
|
|
5964
6732
|
audioConnected = false
|
|
5965
6733
|
if (previousBondState == BluetoothDevice.BOND_BONDING) {
|
|
@@ -6020,10 +6788,12 @@ class MentraLive : SGCManager() {
|
|
|
6020
6788
|
Bridge.sendAudioDisconnected()
|
|
6021
6789
|
}
|
|
6022
6790
|
}
|
|
6023
|
-
BluetoothDevice.BOND_BONDING ->
|
|
6791
|
+
BluetoothDevice.BOND_BONDING -> {
|
|
6024
6792
|
Bridge.log(
|
|
6025
6793
|
"LIVE: CTKD: 🔄 Bonding in progress with device"
|
|
6026
6794
|
)
|
|
6795
|
+
markPairingTiming("ctkd_bonding")
|
|
6796
|
+
}
|
|
6027
6797
|
else ->
|
|
6028
6798
|
Bridge.log(
|
|
6029
6799
|
"LIVE: CTKD: Unknown bond state: " + bondState
|
|
@@ -6058,6 +6828,39 @@ class MentraLive : SGCManager() {
|
|
|
6058
6828
|
}
|
|
6059
6829
|
}
|
|
6060
6830
|
|
|
6831
|
+
/**
|
|
6832
|
+
* Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
|
|
6833
|
+
* onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
|
|
6834
|
+
* (peer terminate) on reconnect when A2DP is already up.
|
|
6835
|
+
*/
|
|
6836
|
+
private fun maybeInitiateCtkdAudioAfterBleReady() {
|
|
6837
|
+
if (isKilled) {
|
|
6838
|
+
return
|
|
6839
|
+
}
|
|
6840
|
+
val device = connectedDevice
|
|
6841
|
+
if (device == null) {
|
|
6842
|
+
Bridge.log("LIVE: CTKD: Skipping initiate — no connected device")
|
|
6843
|
+
return
|
|
6844
|
+
}
|
|
6845
|
+
if (ctkdInitiatedThisGattSession) {
|
|
6846
|
+
return
|
|
6847
|
+
}
|
|
6848
|
+
ctkdInitiatedThisGattSession = true
|
|
6849
|
+
Bridge.log(
|
|
6850
|
+
"LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
|
|
6851
|
+
device.bondState +
|
|
6852
|
+
")"
|
|
6853
|
+
)
|
|
6854
|
+
markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
|
|
6855
|
+
if (device.bondState == BluetoothDevice.BOND_BONDED) {
|
|
6856
|
+
Bridge.log("LIVE: CTKD: Device is already bonded - connecting A2DP audio profile")
|
|
6857
|
+
markPairingTiming("ctkd_already_bonded")
|
|
6858
|
+
connectA2dpProfile(device)
|
|
6859
|
+
} else {
|
|
6860
|
+
createBond(device)
|
|
6861
|
+
}
|
|
6862
|
+
}
|
|
6863
|
+
|
|
6061
6864
|
/**
|
|
6062
6865
|
* Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
|
|
6063
6866
|
* BLE and BT Classic connections automatically
|
|
@@ -6164,6 +6967,7 @@ class MentraLive : SGCManager() {
|
|
|
6164
6967
|
|
|
6165
6968
|
if (state == BluetoothProfile.STATE_CONNECTED) {
|
|
6166
6969
|
Bridge.log("LIVE: A2DP: Already connected to " + device.name)
|
|
6970
|
+
a2dpConnectAttempts = 0
|
|
6167
6971
|
markAudioConnected(device.name)
|
|
6168
6972
|
} else if (state == BluetoothProfile.STATE_DISCONNECTED) {
|
|
6169
6973
|
Bridge.log("LIVE: A2DP: Connecting to " + device.name)
|
|
@@ -6172,35 +6976,48 @@ class MentraLive : SGCManager() {
|
|
|
6172
6976
|
BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
|
|
6173
6977
|
val result = connectMethod.invoke(a2dpProfile, device) as Boolean
|
|
6174
6978
|
Bridge.log("LIVE: A2DP: Connect initiated, result: " + result)
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
markAudioConnected(device.name)
|
|
6979
|
+
if (result) {
|
|
6980
|
+
scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
|
|
6981
|
+
} else {
|
|
6982
|
+
scheduleA2dpConnectRetry(device, "connect() returned false")
|
|
6983
|
+
}
|
|
6181
6984
|
} else if (state == BluetoothProfile.STATE_CONNECTING) {
|
|
6182
|
-
Bridge.log("LIVE: A2DP: Already connecting
|
|
6183
|
-
|
|
6985
|
+
Bridge.log("LIVE: A2DP: Already connecting — waiting for STATE_CONNECTED")
|
|
6986
|
+
scheduleA2dpConnectRetry(device, "still connecting")
|
|
6184
6987
|
} else {
|
|
6185
|
-
|
|
6186
|
-
Bridge.log("LIVE: A2DP: Device disconnecting, will retry in 500ms")
|
|
6187
|
-
handler.postDelayed(
|
|
6188
|
-
Runnable {
|
|
6189
|
-
if (isKilled) {
|
|
6190
|
-
return@Runnable
|
|
6191
|
-
}
|
|
6192
|
-
if (connectedDevice != null && a2dpProfile != null) {
|
|
6193
|
-
connectA2dpWithProxy(connectedDevice)
|
|
6194
|
-
}
|
|
6195
|
-
},
|
|
6196
|
-
500
|
|
6197
|
-
)
|
|
6988
|
+
scheduleA2dpConnectRetry(device, "device disconnecting")
|
|
6198
6989
|
}
|
|
6199
6990
|
} catch (e: Exception) {
|
|
6200
6991
|
Bridge.log("LIVE: A2DP: Error connecting: " + e.message)
|
|
6201
|
-
|
|
6202
|
-
|
|
6992
|
+
scheduleA2dpConnectRetry(device, "exception: ${e.message}")
|
|
6993
|
+
}
|
|
6994
|
+
}
|
|
6995
|
+
|
|
6996
|
+
private fun scheduleA2dpConnectRetry(device: BluetoothDevice, reason: String) {
|
|
6997
|
+
a2dpConnectAttempts++
|
|
6998
|
+
if (a2dpConnectAttempts > A2DP_CONNECT_MAX_ATTEMPTS) {
|
|
6999
|
+
Bridge.log(
|
|
7000
|
+
"LIVE: A2DP: Giving up after $A2DP_CONNECT_MAX_ATTEMPTS attempts ($reason). " +
|
|
7001
|
+
"Classic bond may exist, but A2DP/HFP is not up. BLE/LC3 still works."
|
|
7002
|
+
)
|
|
7003
|
+
return
|
|
6203
7004
|
}
|
|
7005
|
+
Bridge.log(
|
|
7006
|
+
"LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
|
|
7007
|
+
"${A2DP_CONNECT_RETRY_MS}ms ($reason)"
|
|
7008
|
+
)
|
|
7009
|
+
handler.postDelayed(
|
|
7010
|
+
Runnable {
|
|
7011
|
+
if (isKilled || pairingYieldActive || connectedDevice == null) {
|
|
7012
|
+
return@Runnable
|
|
7013
|
+
}
|
|
7014
|
+
val target = connectedDevice ?: return@Runnable
|
|
7015
|
+
if (a2dpProfile != null) {
|
|
7016
|
+
connectA2dpWithProxy(target)
|
|
7017
|
+
}
|
|
7018
|
+
},
|
|
7019
|
+
A2DP_CONNECT_RETRY_MS
|
|
7020
|
+
)
|
|
6204
7021
|
}
|
|
6205
7022
|
|
|
6206
7023
|
/** Helper to mark audio as connected and notify */
|
|
@@ -6218,8 +7035,14 @@ class MentraLive : SGCManager() {
|
|
|
6218
7035
|
Bridge.log(
|
|
6219
7036
|
"LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
|
|
6220
7037
|
)
|
|
7038
|
+
endPairingTiming(
|
|
7039
|
+
"fully_connected",
|
|
7040
|
+
"via=a2dp+glasses_ready queueSize=${sendQueue.size}"
|
|
7041
|
+
)
|
|
6221
7042
|
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
6222
7043
|
updateConnectionState(ConnTypes.CONNECTED)
|
|
7044
|
+
} else {
|
|
7045
|
+
markPairingTiming("a2dp_connected_waiting_glasses_ready")
|
|
6223
7046
|
}
|
|
6224
7047
|
}
|
|
6225
7048
|
|
|
@@ -6243,6 +7066,7 @@ class MentraLive : SGCManager() {
|
|
|
6243
7066
|
}
|
|
6244
7067
|
|
|
6245
7068
|
Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
|
|
7069
|
+
a2dpConnectAttempts = 0
|
|
6246
7070
|
|
|
6247
7071
|
// If we already have the proxy, try to connect directly
|
|
6248
7072
|
if (a2dpProfile != null) {
|
|
@@ -6263,16 +7087,133 @@ class MentraLive : SGCManager() {
|
|
|
6263
7087
|
Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
|
|
6264
7088
|
} else {
|
|
6265
7089
|
Bridge.log(
|
|
6266
|
-
"LIVE: A2DP: Failed to get profile proxy
|
|
7090
|
+
"LIVE: A2DP: Failed to get profile proxy — Classic audio not connected"
|
|
6267
7091
|
)
|
|
6268
|
-
// Still mark as connected - device is bonded and BLE audio (LC3) will work
|
|
6269
|
-
markAudioConnected(device.name)
|
|
6270
7092
|
}
|
|
6271
7093
|
} else {
|
|
6272
7094
|
Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
|
|
6273
7095
|
}
|
|
6274
7096
|
}
|
|
6275
7097
|
|
|
7098
|
+
/**
|
|
7099
|
+
* Drop Classic audio profiles (A2DP + HFP). App disconnect previously only closed BLE GATT /
|
|
7100
|
+
* the A2DP proxy, so Android Settings still showed Mentra Live as connected and the next BLE
|
|
7101
|
+
* reconnect fought a live Classic link (GATT 19). Bond is intentionally kept for reconnect —
|
|
7102
|
+
* only [forget] calls removeBond.
|
|
7103
|
+
*/
|
|
7104
|
+
private fun disconnectClassicProfiles(device: BluetoothDevice?) {
|
|
7105
|
+
if (device == null || bluetoothAdapter == null || context == null) {
|
|
7106
|
+
Bridge.log("LIVE: Classic: skip profile teardown — missing device/adapter/context")
|
|
7107
|
+
return
|
|
7108
|
+
}
|
|
7109
|
+
Bridge.log(
|
|
7110
|
+
"LIVE: Classic: tearing down A2DP/HFP for " +
|
|
7111
|
+
safeDeviceName(device) +
|
|
7112
|
+
" (" +
|
|
7113
|
+
device.address +
|
|
7114
|
+
")"
|
|
7115
|
+
)
|
|
7116
|
+
val isActiveDevice =
|
|
7117
|
+
connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
|
|
7118
|
+
if (isActiveDevice) {
|
|
7119
|
+
isBtClassicConnected = false
|
|
7120
|
+
audioConnected = false
|
|
7121
|
+
}
|
|
7122
|
+
|
|
7123
|
+
if (a2dpProfile != null) {
|
|
7124
|
+
invokeHiddenProfileDisconnect(
|
|
7125
|
+
a2dpProfile!!,
|
|
7126
|
+
device,
|
|
7127
|
+
BluetoothA2dp::class.java,
|
|
7128
|
+
"A2DP"
|
|
7129
|
+
)
|
|
7130
|
+
} else {
|
|
7131
|
+
requestProfileDisconnect(BluetoothProfile.A2DP, device, BluetoothA2dp::class.java, "A2DP")
|
|
7132
|
+
}
|
|
7133
|
+
// HFP/SCO often stays up after A2DP-only teardown (shows as "Bluetooth Headset" in routes).
|
|
7134
|
+
requestProfileDisconnect(
|
|
7135
|
+
BluetoothProfile.HEADSET,
|
|
7136
|
+
device,
|
|
7137
|
+
BluetoothHeadset::class.java,
|
|
7138
|
+
"HFP"
|
|
7139
|
+
)
|
|
7140
|
+
}
|
|
7141
|
+
|
|
7142
|
+
private fun invokeHiddenProfileDisconnect(
|
|
7143
|
+
proxy: BluetoothProfile,
|
|
7144
|
+
device: BluetoothDevice,
|
|
7145
|
+
proxyClass: Class<*>,
|
|
7146
|
+
label: String
|
|
7147
|
+
) {
|
|
7148
|
+
try {
|
|
7149
|
+
val state = proxy.getConnectionState(device)
|
|
7150
|
+
Bridge.log("LIVE: Classic: " + label + " state=" + state)
|
|
7151
|
+
if (state == BluetoothProfile.STATE_CONNECTED ||
|
|
7152
|
+
state == BluetoothProfile.STATE_CONNECTING
|
|
7153
|
+
) {
|
|
7154
|
+
val disconnectMethod =
|
|
7155
|
+
proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
|
|
7156
|
+
val result = disconnectMethod.invoke(proxy, device) as Boolean
|
|
7157
|
+
Bridge.log("LIVE: Classic: " + label + " disconnect result=" + result)
|
|
7158
|
+
} else {
|
|
7159
|
+
Bridge.log("LIVE: Classic: " + label + " already idle — nothing to disconnect")
|
|
7160
|
+
}
|
|
7161
|
+
} catch (e: Exception) {
|
|
7162
|
+
Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
|
|
7163
|
+
}
|
|
7164
|
+
}
|
|
7165
|
+
|
|
7166
|
+
private fun requestProfileDisconnect(
|
|
7167
|
+
profile: Int,
|
|
7168
|
+
device: BluetoothDevice,
|
|
7169
|
+
proxyClass: Class<*>,
|
|
7170
|
+
label: String
|
|
7171
|
+
) {
|
|
7172
|
+
val adapter = bluetoothAdapter ?: return
|
|
7173
|
+
val ctx = context ?: return
|
|
7174
|
+
try {
|
|
7175
|
+
val ok =
|
|
7176
|
+
adapter.getProfileProxy(
|
|
7177
|
+
ctx,
|
|
7178
|
+
object : BluetoothProfile.ServiceListener {
|
|
7179
|
+
override fun onServiceConnected(
|
|
7180
|
+
profileType: Int,
|
|
7181
|
+
proxy: BluetoothProfile
|
|
7182
|
+
) {
|
|
7183
|
+
if (profileType != profile) {
|
|
7184
|
+
return
|
|
7185
|
+
}
|
|
7186
|
+
try {
|
|
7187
|
+
invokeHiddenProfileDisconnect(
|
|
7188
|
+
proxy,
|
|
7189
|
+
device,
|
|
7190
|
+
proxyClass,
|
|
7191
|
+
label
|
|
7192
|
+
)
|
|
7193
|
+
} finally {
|
|
7194
|
+
try {
|
|
7195
|
+
adapter.closeProfileProxy(profile, proxy)
|
|
7196
|
+
} catch (e: Exception) {
|
|
7197
|
+
Bridge.log(
|
|
7198
|
+
"LIVE: Classic: error closing " +
|
|
7199
|
+
label +
|
|
7200
|
+
" proxy: " +
|
|
7201
|
+
e.message
|
|
7202
|
+
)
|
|
7203
|
+
}
|
|
7204
|
+
}
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
override fun onServiceDisconnected(profileType: Int) {}
|
|
7208
|
+
},
|
|
7209
|
+
profile
|
|
7210
|
+
)
|
|
7211
|
+
Bridge.log("LIVE: Classic: requested " + label + " proxy for disconnect, ok=" + ok)
|
|
7212
|
+
} catch (e: Exception) {
|
|
7213
|
+
Bridge.log("LIVE: Classic: failed to request " + label + " proxy: " + e.message)
|
|
7214
|
+
}
|
|
7215
|
+
}
|
|
7216
|
+
|
|
6276
7217
|
/** Close the A2DP profile proxy */
|
|
6277
7218
|
private fun closeA2dpProxy() {
|
|
6278
7219
|
if (a2dpProfile != null && bluetoothAdapter != null) {
|
|
@@ -6288,6 +7229,14 @@ class MentraLive : SGCManager() {
|
|
|
6288
7229
|
|
|
6289
7230
|
// Mark as killed to prevent reconnection attempts
|
|
6290
7231
|
isKilled = true
|
|
7232
|
+
explicitConnectByName = false
|
|
7233
|
+
pairingYieldActive = false
|
|
7234
|
+
pairingYieldAwaitingReclaim = false
|
|
7235
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
7236
|
+
pairingYieldEndRunnable = null
|
|
7237
|
+
unpairFlushRunnable?.let { handler.removeCallbacks(it) }
|
|
7238
|
+
unpairFlushRunnable = null
|
|
7239
|
+
unpairFlushPending = false
|
|
6291
7240
|
|
|
6292
7241
|
// Stop scanning if in progress
|
|
6293
7242
|
if (isScanning) {
|
|
@@ -6298,7 +7247,10 @@ class MentraLive : SGCManager() {
|
|
|
6298
7247
|
// CTKD Implementation: Unregister bonding receiver
|
|
6299
7248
|
unregisterBondingReceiver()
|
|
6300
7249
|
|
|
6301
|
-
//
|
|
7250
|
+
// Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
|
|
7251
|
+
// phone's system Bluetooth UI showing Mentra Live still connected.
|
|
7252
|
+
val classicDevice = connectedDevice
|
|
7253
|
+
disconnectClassicProfiles(classicDevice)
|
|
6302
7254
|
closeA2dpProxy()
|
|
6303
7255
|
|
|
6304
7256
|
// Stop readiness check loop
|
|
@@ -6369,6 +7321,9 @@ class MentraLive : SGCManager() {
|
|
|
6369
7321
|
reconnectAttempts = 0
|
|
6370
7322
|
isReconnecting = false
|
|
6371
7323
|
glassesReady = false
|
|
7324
|
+
glassesReadyReceived = false
|
|
7325
|
+
audioConnected = false
|
|
7326
|
+
ctkdInitiatedThisGattSession = false
|
|
6372
7327
|
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
6373
7328
|
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
6374
7329
|
|
|
@@ -7388,6 +8343,7 @@ class MentraLive : SGCManager() {
|
|
|
7388
8343
|
/** Sends phone_ready to (re)start the glasses-driven readiness flow. */
|
|
7389
8344
|
private fun sendPhoneReady(reason: String) {
|
|
7390
8345
|
Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
|
|
8346
|
+
markPairingTiming("phone_ready_sent", "reason=$reason queueSize=${sendQueue.size}")
|
|
7391
8347
|
val readyMsg = JSONObject()
|
|
7392
8348
|
readyMsg.put("type", "phone_ready")
|
|
7393
8349
|
readyMsg.put("timestamp", System.currentTimeMillis())
|
|
@@ -7511,6 +8467,10 @@ class MentraLive : SGCManager() {
|
|
|
7511
8467
|
payload
|
|
7512
8468
|
)
|
|
7513
8469
|
Bridge.log("LIVE: Sending BLE wire v2 handshake")
|
|
8470
|
+
markPairingTiming(
|
|
8471
|
+
"wire_handshake_sent",
|
|
8472
|
+
"attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
|
|
8473
|
+
)
|
|
7514
8474
|
wireHandshakeQueued = true
|
|
7515
8475
|
wireHandshakeSentGeneration = wireSessionGeneration
|
|
7516
8476
|
queueData(packed, null)
|
|
@@ -7537,6 +8497,10 @@ class MentraLive : SGCManager() {
|
|
|
7537
8497
|
WIRE_HANDSHAKE_MAX_ATTEMPTS +
|
|
7538
8498
|
") - re-arming"
|
|
7539
8499
|
)
|
|
8500
|
+
markPairingTiming(
|
|
8501
|
+
"wire_handshake_unanswered",
|
|
8502
|
+
"attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
|
|
8503
|
+
)
|
|
7540
8504
|
wireHandshakeQueued = false
|
|
7541
8505
|
if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
|
|
7542
8506
|
maybeSendWireHandshake()
|
|
@@ -7565,6 +8529,7 @@ class MentraLive : SGCManager() {
|
|
|
7565
8529
|
Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
|
|
7566
8530
|
return
|
|
7567
8531
|
}
|
|
8532
|
+
markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
|
|
7568
8533
|
activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
|
|
7569
8534
|
}
|
|
7570
8535
|
|
|
@@ -8238,6 +9203,19 @@ class MentraLive : SGCManager() {
|
|
|
8238
9203
|
}
|
|
8239
9204
|
}
|
|
8240
9205
|
|
|
9206
|
+
override fun sendWifiAdbState(enabled: Boolean) {
|
|
9207
|
+
Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state to glasses - enabled: " + enabled)
|
|
9208
|
+
try {
|
|
9209
|
+
val command = JSONObject()
|
|
9210
|
+
command.put("type", "set_wifi_adb_state")
|
|
9211
|
+
command.put("enabled", enabled)
|
|
9212
|
+
sendJson(command, true)
|
|
9213
|
+
Bridge.log("LIVE: 🔧 ✅ Wi-Fi ADB state command sent successfully")
|
|
9214
|
+
} catch (e: JSONException) {
|
|
9215
|
+
Log.e(TAG, "🔧 💥 Error creating Wi-Fi ADB state JSON", e)
|
|
9216
|
+
}
|
|
9217
|
+
}
|
|
9218
|
+
|
|
8241
9219
|
override fun sendSetSystemTime(timestampMs: Long) {
|
|
8242
9220
|
Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
|
|
8243
9221
|
try {
|
|
@@ -9425,6 +10403,21 @@ class MentraLive : SGCManager() {
|
|
|
9425
10403
|
stopVideoRecording(requestId, null, null)
|
|
9426
10404
|
}
|
|
9427
10405
|
|
|
10406
|
+
override fun queryVideoRecordingStatus(requestId: String) {
|
|
10407
|
+
if (!isConnected) {
|
|
10408
|
+
Log.w(TAG, "Cannot query video recording status - not connected")
|
|
10409
|
+
return
|
|
10410
|
+
}
|
|
10411
|
+
try {
|
|
10412
|
+
val json = JSONObject()
|
|
10413
|
+
json.put("type", "get_video_recording_status")
|
|
10414
|
+
json.put("requestId", requestId)
|
|
10415
|
+
sendJson(json, true)
|
|
10416
|
+
} catch (e: JSONException) {
|
|
10417
|
+
Log.e(TAG, "Failed to create video recording status query", e)
|
|
10418
|
+
}
|
|
10419
|
+
}
|
|
10420
|
+
|
|
9428
10421
|
override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
9429
10422
|
Bridge.log(
|
|
9430
10423
|
"LIVE: Stopping video recording: requestId=" +
|
|
@@ -9475,20 +10468,6 @@ class MentraLive : SGCManager() {
|
|
|
9475
10468
|
val sequenceNumber = data[1]
|
|
9476
10469
|
val receiveTime = System.currentTimeMillis()
|
|
9477
10470
|
|
|
9478
|
-
// Basic sequence validation
|
|
9479
|
-
if (lastReceivedLc3Sequence != (-1).toByte() &&
|
|
9480
|
-
(lastReceivedLc3Sequence + 1).toByte() != sequenceNumber
|
|
9481
|
-
) {
|
|
9482
|
-
Log.w(
|
|
9483
|
-
TAG,
|
|
9484
|
-
"LC3 packet sequence mismatch. Expected: " +
|
|
9485
|
-
(lastReceivedLc3Sequence + 1) +
|
|
9486
|
-
", Got: " +
|
|
9487
|
-
sequenceNumber
|
|
9488
|
-
)
|
|
9489
|
-
}
|
|
9490
|
-
lastReceivedLc3Sequence = sequenceNumber
|
|
9491
|
-
|
|
9492
10471
|
val lc3Data = Arrays.copyOfRange(data, 2, data.size)
|
|
9493
10472
|
|
|
9494
10473
|
// Enhanced LC3 packet logging and saving
|
|
@@ -9500,7 +10479,11 @@ class MentraLive : SGCManager() {
|
|
|
9500
10479
|
|
|
9501
10480
|
// Forward raw LC3 to DeviceManager (matches iOS behavior)
|
|
9502
10481
|
// MentraLive uses 40-byte LC3 frames
|
|
9503
|
-
DeviceManager.getInstance().handleGlassesMicData(
|
|
10482
|
+
DeviceManager.getInstance().handleGlassesMicData(
|
|
10483
|
+
lc3Data,
|
|
10484
|
+
LC3_FRAME_SIZE,
|
|
10485
|
+
sequenceNumber.toInt() and 0xff,
|
|
10486
|
+
)
|
|
9504
10487
|
|
|
9505
10488
|
// Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
|
|
9506
10489
|
// } else {
|