@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-beta.103
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 +67 -25
- 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 +205 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -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/ReleaseChangelog.kt +61 -0
- 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 +1186 -191
- 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/ReleaseChangelogTest.kt +13 -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/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +82 -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/changelogs.d.ts +7 -0
- package/build/changelogs.d.ts.map +1 -0
- package/build/changelogs.js +44 -0
- package/build/changelogs.js.map +1 -0
- package/build/generated/changelogCatalog.d.ts +6 -0
- package/build/generated/changelogCatalog.d.ts.map +1 -0
- package/build/generated/changelogCatalog.js +8 -0
- package/build/generated/changelogCatalog.js.map +1 -0
- 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 +24 -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/build/ota-transport/index.d.ts +67 -0
- package/build/ota-transport/index.d.ts.map +1 -0
- package/build/ota-transport/index.js +58 -0
- package/build/ota-transport/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 +143 -13
- package/ios/Source/DeviceStore.swift +2 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
- package/ios/Source/MentraBluetoothSDK.swift +91 -6
- package/ios/Source/OtaManifest.swift +5 -7
- package/ios/Source/ReleaseChangelog.swift +65 -0
- 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 +453 -31
- 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 +160 -0
- package/ios/Tests/ReleaseChangelogTests.swift +13 -0
- package/package.json +10 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/public-ota-api.test.mjs +37 -0
- 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 +95 -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/changelogs.ts +44 -0
- package/src/generated/changelogCatalog.ts +9 -0
- package/src/generated/releaseMetadata.ts +20 -0
- package/src/index.ts +43 -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
- package/src/ota-transport/index.ts +116 -0
|
@@ -10,6 +10,7 @@ import android.bluetooth.BluetoothGatt
|
|
|
10
10
|
import android.bluetooth.BluetoothGattCallback
|
|
11
11
|
import android.bluetooth.BluetoothGattCharacteristic
|
|
12
12
|
import android.bluetooth.BluetoothGattDescriptor
|
|
13
|
+
import android.bluetooth.BluetoothHeadset
|
|
13
14
|
import android.bluetooth.BluetoothManager
|
|
14
15
|
import android.bluetooth.BluetoothProfile
|
|
15
16
|
import android.bluetooth.le.BluetoothLeScanner
|
|
@@ -76,6 +77,38 @@ import org.json.JSONArray
|
|
|
76
77
|
import org.json.JSONException
|
|
77
78
|
import org.json.JSONObject
|
|
78
79
|
|
|
80
|
+
internal fun hasRequiredMentraLiveCoreCharacteristics(
|
|
81
|
+
hasRxCharacteristic: Boolean,
|
|
82
|
+
hasTxCharacteristic: Boolean,
|
|
83
|
+
): Boolean = hasRxCharacteristic && hasTxCharacteristic
|
|
84
|
+
|
|
85
|
+
internal fun isPendingMentraLivePairingTarget(
|
|
86
|
+
connectedName: String,
|
|
87
|
+
connectedAddress: String,
|
|
88
|
+
pendingName: String,
|
|
89
|
+
pendingAddress: String,
|
|
90
|
+
): Boolean {
|
|
91
|
+
if (pendingAddress.isNotEmpty()) {
|
|
92
|
+
return connectedAddress.equals(pendingAddress, ignoreCase = true)
|
|
93
|
+
}
|
|
94
|
+
return pendingName.isNotEmpty() && connectedName == pendingName
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
internal fun shouldRecoverConnectedMentraLivePairingTarget(
|
|
98
|
+
isConnected: Boolean,
|
|
99
|
+
connectedName: String,
|
|
100
|
+
connectedAddress: String,
|
|
101
|
+
pendingName: String,
|
|
102
|
+
pendingAddress: String,
|
|
103
|
+
): Boolean =
|
|
104
|
+
isConnected &&
|
|
105
|
+
isPendingMentraLivePairingTarget(
|
|
106
|
+
connectedName,
|
|
107
|
+
connectedAddress,
|
|
108
|
+
pendingName,
|
|
109
|
+
pendingAddress,
|
|
110
|
+
)
|
|
111
|
+
|
|
79
112
|
/**
|
|
80
113
|
* Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
|
|
81
114
|
* glasses
|
|
@@ -83,6 +116,12 @@ import org.json.JSONObject
|
|
|
83
116
|
* Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
|
|
84
117
|
* display-related methods are stubbed out and will log a message but not actually display anything.
|
|
85
118
|
*/
|
|
119
|
+
private fun jsonNullableString(json: org.json.JSONObject, key: String): String? {
|
|
120
|
+
if (!json.has(key) || json.isNull(key)) return null
|
|
121
|
+
val value = json.optString(key, "")
|
|
122
|
+
return value.takeIf { it.isNotEmpty() }
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
class MentraLive : SGCManager() {
|
|
87
126
|
private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
|
|
88
127
|
|
|
@@ -99,6 +138,9 @@ class MentraLive : SGCManager() {
|
|
|
99
138
|
private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
|
|
100
139
|
private const val LOUDNESS_GATE_SWITCH_TYPE = 10
|
|
101
140
|
private const val BES2700_MTU_LIMIT = 509
|
|
141
|
+
private const val A2DP_CONNECT_MAX_ATTEMPTS = 5
|
|
142
|
+
private const val A2DP_CONNECT_RETRY_MS = 800L
|
|
143
|
+
private const val UNPAIR_FLUSH_MS = 400L
|
|
102
144
|
|
|
103
145
|
// L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
|
|
104
146
|
// PSM. When the phone opens the channel, the glasses send file packets over it instead
|
|
@@ -142,6 +184,253 @@ class MentraLive : SGCManager() {
|
|
|
142
184
|
10000 // 10 seconds for reconnection scans (faster than 60s default)
|
|
143
185
|
private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
|
|
144
186
|
10_000L // 10s before first scan after shutdown
|
|
187
|
+
|
|
188
|
+
private const val MENTRA_MANUFACTURER_ID = 0xB822
|
|
189
|
+
|
|
190
|
+
private fun manufacturerData(result: ScanResult): ByteArray? {
|
|
191
|
+
return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun isPairingDiscoverable(result: ScanResult): Boolean {
|
|
195
|
+
return MentraLivePairingAdvertisementParser.parse(manufacturerData(result))?.pairingMode ==
|
|
196
|
+
true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun advertisesPairingFlag(result: ScanResult): Boolean {
|
|
200
|
+
return MentraLivePairingAdvertisementParser.parse(manufacturerData(result)) != null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
|
|
204
|
+
val advertisement =
|
|
205
|
+
MentraLivePairingAdvertisementParser.parse(manufacturerData(result))
|
|
206
|
+
?: return Triple(false, null, false)
|
|
207
|
+
return Triple(advertisement.pairingMode, advertisement.pairingCode, true)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fun isMentraLiveBluetoothName(name: String?): Boolean {
|
|
211
|
+
if (name.isNullOrEmpty()) {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
return name == "Xy_A" ||
|
|
215
|
+
name.startsWith("XyBLE_") ||
|
|
216
|
+
name.startsWith("MENTRA_LIVE_BLE") ||
|
|
217
|
+
name.startsWith("MENTRA_LIVE_BT") ||
|
|
218
|
+
name.lowercase().startsWith("mentra_live")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private fun safeBondedDeviceName(device: BluetoothDevice): String {
|
|
222
|
+
return try {
|
|
223
|
+
device.name ?: ""
|
|
224
|
+
} catch (_: SecurityException) {
|
|
225
|
+
""
|
|
226
|
+
} catch (_: Exception) {
|
|
227
|
+
""
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private fun removeBondStandalone(device: BluetoothDevice): Boolean {
|
|
232
|
+
return try {
|
|
233
|
+
val method = device.javaClass.getMethod("removeBond")
|
|
234
|
+
val result = method.invoke(device) as Boolean
|
|
235
|
+
Bridge.log(
|
|
236
|
+
"LIVE: CTKD: removeBond " +
|
|
237
|
+
safeBondedDeviceName(device) +
|
|
238
|
+
" (" +
|
|
239
|
+
device.address +
|
|
240
|
+
") result=" +
|
|
241
|
+
result
|
|
242
|
+
)
|
|
243
|
+
result
|
|
244
|
+
} catch (e: Exception) {
|
|
245
|
+
Bridge.log("LIVE: CTKD: removeBond error: " + e.message)
|
|
246
|
+
false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private fun invokeHiddenProfileDisconnectStandalone(
|
|
251
|
+
proxy: BluetoothProfile,
|
|
252
|
+
device: BluetoothDevice,
|
|
253
|
+
proxyClass: Class<*>,
|
|
254
|
+
label: String
|
|
255
|
+
) {
|
|
256
|
+
try {
|
|
257
|
+
val state = proxy.getConnectionState(device)
|
|
258
|
+
if (state == BluetoothProfile.STATE_CONNECTED ||
|
|
259
|
+
state == BluetoothProfile.STATE_CONNECTING
|
|
260
|
+
) {
|
|
261
|
+
val disconnectMethod =
|
|
262
|
+
proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
|
|
263
|
+
val result = disconnectMethod.invoke(proxy, device) as Boolean
|
|
264
|
+
Bridge.log(
|
|
265
|
+
"LIVE: Classic: " +
|
|
266
|
+
label +
|
|
267
|
+
" disconnect " +
|
|
268
|
+
device.address +
|
|
269
|
+
" result=" +
|
|
270
|
+
result
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
} catch (e: Exception) {
|
|
274
|
+
Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private fun requestProfileDisconnectStandalone(
|
|
279
|
+
context: Context,
|
|
280
|
+
adapter: BluetoothAdapter,
|
|
281
|
+
profile: Int,
|
|
282
|
+
device: BluetoothDevice,
|
|
283
|
+
proxyClass: Class<*>,
|
|
284
|
+
label: String
|
|
285
|
+
) {
|
|
286
|
+
try {
|
|
287
|
+
adapter.getProfileProxy(
|
|
288
|
+
context,
|
|
289
|
+
object : BluetoothProfile.ServiceListener {
|
|
290
|
+
override fun onServiceConnected(
|
|
291
|
+
profileType: Int,
|
|
292
|
+
proxy: BluetoothProfile
|
|
293
|
+
) {
|
|
294
|
+
if (profileType != profile) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
invokeHiddenProfileDisconnectStandalone(
|
|
299
|
+
proxy,
|
|
300
|
+
device,
|
|
301
|
+
proxyClass,
|
|
302
|
+
label
|
|
303
|
+
)
|
|
304
|
+
} finally {
|
|
305
|
+
try {
|
|
306
|
+
adapter.closeProfileProxy(profile, proxy)
|
|
307
|
+
} catch (_: Exception) {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
override fun onServiceDisconnected(profileType: Int) {}
|
|
313
|
+
},
|
|
314
|
+
profile
|
|
315
|
+
)
|
|
316
|
+
} catch (e: Exception) {
|
|
317
|
+
Bridge.log("LIVE: Classic: request " + label + " proxy failed: " + e.message)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Drop A2DP/HFP + removeBond for every bonded Mentra Live unit. Safe when no SGC
|
|
323
|
+
* instance exists — [DeviceManager.forget] often runs after disconnect() nulled sgc
|
|
324
|
+
* (abandonAttempt), which previously skipped MentraLive.forget() entirely and left
|
|
325
|
+
* Classic ACL up so glasses never re-advertised.
|
|
326
|
+
*/
|
|
327
|
+
@JvmStatic
|
|
328
|
+
@JvmOverloads
|
|
329
|
+
fun unbondBondedMentraLiveDevices(context: Context?, extraAddress: String? = null) {
|
|
330
|
+
if (context == null) {
|
|
331
|
+
Bridge.log("LIVE: CTKD: unbondBondedMentraLiveDevices — no context")
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
val adapter =
|
|
335
|
+
try {
|
|
336
|
+
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
|
337
|
+
?.adapter
|
|
338
|
+
} catch (e: Exception) {
|
|
339
|
+
Bridge.log("LIVE: CTKD: unbond — adapter error: " + e.message)
|
|
340
|
+
null
|
|
341
|
+
}
|
|
342
|
+
if (adapter == null) {
|
|
343
|
+
Bridge.log("LIVE: CTKD: unbond — BluetoothAdapter unavailable")
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
val bonded =
|
|
347
|
+
try {
|
|
348
|
+
adapter.bondedDevices?.toList().orEmpty()
|
|
349
|
+
} catch (e: Exception) {
|
|
350
|
+
Bridge.log("LIVE: CTKD: unbond — cannot list bonded: " + e.message)
|
|
351
|
+
emptyList()
|
|
352
|
+
}
|
|
353
|
+
val extra = extraAddress?.uppercase()
|
|
354
|
+
val targets =
|
|
355
|
+
bonded.filter {
|
|
356
|
+
isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
|
|
357
|
+
(extra != null && it.address.equals(extra, ignoreCase = true))
|
|
358
|
+
}
|
|
359
|
+
if (targets.isEmpty()) {
|
|
360
|
+
Bridge.log("LIVE: CTKD: unbond — no bonded Mentra Live devices")
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
for (device in targets) {
|
|
364
|
+
Bridge.log(
|
|
365
|
+
"LIVE: CTKD: tearing down bonded Mentra Live " +
|
|
366
|
+
safeBondedDeviceName(device) +
|
|
367
|
+
" (" +
|
|
368
|
+
device.address +
|
|
369
|
+
")"
|
|
370
|
+
)
|
|
371
|
+
requestProfileDisconnectStandalone(
|
|
372
|
+
context,
|
|
373
|
+
adapter,
|
|
374
|
+
BluetoothProfile.A2DP,
|
|
375
|
+
device,
|
|
376
|
+
BluetoothA2dp::class.java,
|
|
377
|
+
"A2DP"
|
|
378
|
+
)
|
|
379
|
+
requestProfileDisconnectStandalone(
|
|
380
|
+
context,
|
|
381
|
+
adapter,
|
|
382
|
+
BluetoothProfile.HEADSET,
|
|
383
|
+
device,
|
|
384
|
+
BluetoothHeadset::class.java,
|
|
385
|
+
"HFP"
|
|
386
|
+
)
|
|
387
|
+
removeBondStandalone(device)
|
|
388
|
+
}
|
|
389
|
+
Handler(Looper.getMainLooper())
|
|
390
|
+
.postDelayed({ retryUnbondIfStillPaired(context, extraAddress) }, 700)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private fun retryUnbondIfStillPaired(context: Context?, extraAddress: String?) {
|
|
394
|
+
if (context == null) {
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
val adapter =
|
|
398
|
+
try {
|
|
399
|
+
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
|
400
|
+
?.adapter
|
|
401
|
+
} catch (_: Exception) {
|
|
402
|
+
null
|
|
403
|
+
}
|
|
404
|
+
?: return
|
|
405
|
+
val extra = extraAddress?.uppercase()
|
|
406
|
+
val leftover =
|
|
407
|
+
try {
|
|
408
|
+
adapter.bondedDevices?.toList().orEmpty()
|
|
409
|
+
} catch (_: Exception) {
|
|
410
|
+
emptyList()
|
|
411
|
+
}
|
|
412
|
+
.filter {
|
|
413
|
+
isMentraLiveBluetoothName(safeBondedDeviceName(it)) ||
|
|
414
|
+
(extra != null &&
|
|
415
|
+
it.address.equals(extra, ignoreCase = true))
|
|
416
|
+
}
|
|
417
|
+
if (leftover.isEmpty()) {
|
|
418
|
+
Bridge.log("LIVE: CTKD: unbond verify — no Mentra Live bonds remain")
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
for (device in leftover) {
|
|
422
|
+
Bridge.log(
|
|
423
|
+
"LIVE: CTKD: unbond retry still paired " +
|
|
424
|
+
safeBondedDeviceName(device) +
|
|
425
|
+
" (" +
|
|
426
|
+
device.address +
|
|
427
|
+
") bondState=" +
|
|
428
|
+
device.bondState
|
|
429
|
+
)
|
|
430
|
+
removeBondStandalone(device)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
145
434
|
private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
|
|
146
435
|
|
|
147
436
|
// Keep-alive parameters
|
|
@@ -253,6 +542,16 @@ class MentraLive : SGCManager() {
|
|
|
253
542
|
|
|
254
543
|
private var reconnectAttempts = 0
|
|
255
544
|
private var isReconnecting = false // Track if we're in reconnection mode
|
|
545
|
+
// Manual discovery/connect invalidates delayed reconnect work from an earlier GATT failure.
|
|
546
|
+
private var reconnectGeneration = 0
|
|
547
|
+
// Pair Glasses discovery is an explicit no-reconnect zone. It ends only when
|
|
548
|
+
// the user selects a scan result (connectById) or the manager is torn down.
|
|
549
|
+
private var manualDiscoveryActive = false
|
|
550
|
+
// Set by connectById() so a post-disconnect name scan actually GATT-connects.
|
|
551
|
+
// Discovery-only scans (findCompatibleDevices) leave this false so a leftover
|
|
552
|
+
// savedDeviceName cannot auto-connect a spoofed advertiser (iOS parity: iOS
|
|
553
|
+
// connects on saved-name match because connectById writes that name first).
|
|
554
|
+
private var explicitConnectByName = false
|
|
256
555
|
/**
|
|
257
556
|
* Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
|
|
258
557
|
* so glasses can reboot.
|
|
@@ -276,18 +575,35 @@ class MentraLive : SGCManager() {
|
|
|
276
575
|
private val fileProcessingHandler = Handler(fileProcessingThread.looper)
|
|
277
576
|
private var scheduler: ScheduledExecutorService? = null
|
|
278
577
|
private var isScanning = false
|
|
578
|
+
// Prevent an old scan timeout from stopping a newer manual scan.
|
|
579
|
+
private var scanGeneration = 0
|
|
279
580
|
private var isConnecting = false
|
|
280
581
|
private var isKilled = false
|
|
281
582
|
|
|
583
|
+
/** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
|
|
584
|
+
private var pairingYieldActive = false
|
|
585
|
+
private var pairingYieldEndRunnable: Runnable? = null
|
|
586
|
+
/** After yield ends, auth/not-owner failures may clear saved glasses. */
|
|
587
|
+
private var pairingYieldAwaitingReclaim = false
|
|
588
|
+
/** Hold GATT teardown until the unpair write can leave the phone. */
|
|
589
|
+
private var unpairFlushPending = false
|
|
590
|
+
private var unpairFlushRunnable: Runnable? = null
|
|
591
|
+
|
|
282
592
|
// CTKD (Cross-Transport Key Derivation) support for BES devices
|
|
283
593
|
private var isBondingReceiverRegistered = false
|
|
284
594
|
private var isBtClassicConnected = false
|
|
285
595
|
private var bondingReceiver: BroadcastReceiver? = null
|
|
286
596
|
private var bondingRetryCount = 0
|
|
287
597
|
|
|
598
|
+
// Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
|
|
599
|
+
private var pairingTimingActive = false
|
|
600
|
+
private var pairingTimingStartMs = 0L
|
|
601
|
+
private var pairingTimingLastMs = 0L
|
|
602
|
+
|
|
288
603
|
// A2DP profile connection for already-bonded devices
|
|
289
604
|
private var a2dpProfile: BluetoothA2dp? = null
|
|
290
605
|
private var isA2dpProxyRegistered = false
|
|
606
|
+
private var a2dpConnectAttempts = 0
|
|
291
607
|
|
|
292
608
|
private data class OutgoingBleCommandTraceInfo(
|
|
293
609
|
val commandType: String,
|
|
@@ -609,6 +925,9 @@ class MentraLive : SGCManager() {
|
|
|
609
925
|
// Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
|
|
610
926
|
private var glassesReadyReceived = false
|
|
611
927
|
private var audioConnected = false
|
|
928
|
+
// CTKD/A2DP must wait until GATT notifications are armed; starting Classic while the
|
|
929
|
+
// BLE link is still discovering services commonly yields GATT status 19 on Samsung.
|
|
930
|
+
private var ctkdInitiatedThisGattSession = false
|
|
612
931
|
|
|
613
932
|
// Micbeat tracking - periodically enable custom audio TX
|
|
614
933
|
private var micBeatHandler = Handler(Looper.getMainLooper())
|
|
@@ -624,7 +943,6 @@ class MentraLive : SGCManager() {
|
|
|
624
943
|
private val secureRandom = SecureRandom()
|
|
625
944
|
private val deviceId = System.currentTimeMillis() xor Random().nextLong()
|
|
626
945
|
|
|
627
|
-
private var lastReceivedLc3Sequence: Byte = -1
|
|
628
946
|
private var lc3SequenceNumber: Byte = 0
|
|
629
947
|
private var lc3DecoderPtr = 0L
|
|
630
948
|
private var lc3AudioPlayer: Lc3Player? = null
|
|
@@ -811,6 +1129,9 @@ class MentraLive : SGCManager() {
|
|
|
811
1129
|
}
|
|
812
1130
|
|
|
813
1131
|
private fun updateConnectionState(state: String) {
|
|
1132
|
+
if (state == ConnTypes.DISCONNECTED) {
|
|
1133
|
+
DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
|
|
1134
|
+
}
|
|
814
1135
|
val isEqual = state == connectionState
|
|
815
1136
|
if (isEqual) {
|
|
816
1137
|
if (state == ConnTypes.DISCONNECTED) {
|
|
@@ -833,6 +1154,7 @@ class MentraLive : SGCManager() {
|
|
|
833
1154
|
// same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
|
|
834
1155
|
DeviceStore.apply("glasses", "serialNumber", "")
|
|
835
1156
|
DeviceStore.apply("glasses", "bluetoothMacAddress", "")
|
|
1157
|
+
DeviceStore.apply("glasses", "wifiMacAddress", "")
|
|
836
1158
|
}
|
|
837
1159
|
|
|
838
1160
|
// Actually update the connection state!
|
|
@@ -905,6 +1227,10 @@ class MentraLive : SGCManager() {
|
|
|
905
1227
|
|
|
906
1228
|
/** Starts BLE scanning for Mentra Live glasses */
|
|
907
1229
|
private fun startScan() {
|
|
1230
|
+
if (pairingYieldActive) {
|
|
1231
|
+
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
908
1234
|
if (bluetoothAdapter == null || isScanning) {
|
|
909
1235
|
return
|
|
910
1236
|
}
|
|
@@ -963,6 +1289,7 @@ class MentraLive : SGCManager() {
|
|
|
963
1289
|
)
|
|
964
1290
|
}
|
|
965
1291
|
|
|
1292
|
+
val generation = ++scanGeneration
|
|
966
1293
|
isScanning = true
|
|
967
1294
|
bluetoothScanner!!.startScan(filters, settings, scanCallback)
|
|
968
1295
|
|
|
@@ -970,6 +1297,9 @@ class MentraLive : SGCManager() {
|
|
|
970
1297
|
handler.postDelayed(
|
|
971
1298
|
object : Runnable {
|
|
972
1299
|
override fun run() {
|
|
1300
|
+
if (generation != scanGeneration) {
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
973
1303
|
if (isScanning) {
|
|
974
1304
|
stopScan()
|
|
975
1305
|
emitStopScanEvent()
|
|
@@ -1013,11 +1343,13 @@ class MentraLive : SGCManager() {
|
|
|
1013
1343
|
|
|
1014
1344
|
/** Stops BLE scanning */
|
|
1015
1345
|
override fun stopScan() {
|
|
1346
|
+
manualDiscoveryActive = false
|
|
1016
1347
|
if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
|
|
1017
1348
|
return
|
|
1018
1349
|
}
|
|
1019
1350
|
|
|
1020
1351
|
try {
|
|
1352
|
+
scanGeneration++
|
|
1021
1353
|
bluetoothScanner!!.stopScan(scanCallback)
|
|
1022
1354
|
isScanning = false
|
|
1023
1355
|
DeviceStore.apply("bluetooth", "searching", false)
|
|
@@ -1041,6 +1373,48 @@ class MentraLive : SGCManager() {
|
|
|
1041
1373
|
Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
|
|
1042
1374
|
}
|
|
1043
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* A selected pairing target can stop advertising once its GATT link comes up, before
|
|
1378
|
+
* readiness promotes it to the default device. Re-emit only that explicit pending target;
|
|
1379
|
+
* an established owner's connected glasses have no pending name/address and stay hidden.
|
|
1380
|
+
*/
|
|
1381
|
+
private fun emitConnectedPendingDeviceForPairingScan() {
|
|
1382
|
+
if (!manualDiscoveryActive || isReconnecting || pairingYieldActive) return
|
|
1383
|
+
val device = connectedDevice ?: bluetoothGatt?.device ?: return
|
|
1384
|
+
val name = safeBondedDeviceName(device)
|
|
1385
|
+
if (!isMentraLiveBluetoothName(name)) return
|
|
1386
|
+
|
|
1387
|
+
val pendingName = DeviceStore.get("bluetooth", "pending_device_name") as? String ?: ""
|
|
1388
|
+
val pendingAddress =
|
|
1389
|
+
DeviceStore.get("bluetooth", "pending_device_address") as? String ?: ""
|
|
1390
|
+
val pendingSecurePairingCapable =
|
|
1391
|
+
DeviceStore.get("bluetooth", "pending_device_secure_pairing_capable") as? Boolean
|
|
1392
|
+
val address = device.address ?: ""
|
|
1393
|
+
if (
|
|
1394
|
+
!shouldRecoverConnectedMentraLivePairingTarget(
|
|
1395
|
+
isConnected,
|
|
1396
|
+
name,
|
|
1397
|
+
address,
|
|
1398
|
+
pendingName,
|
|
1399
|
+
pendingAddress,
|
|
1400
|
+
)
|
|
1401
|
+
) {
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
Bridge.log(
|
|
1406
|
+
"LIVE: Pairing scan: recovering connected pending target $name ($address)"
|
|
1407
|
+
)
|
|
1408
|
+
Bridge.sendDiscoveredDevice(
|
|
1409
|
+
DeviceTypes.LIVE,
|
|
1410
|
+
name,
|
|
1411
|
+
address,
|
|
1412
|
+
pairingMode = true,
|
|
1413
|
+
pairingCode = null,
|
|
1414
|
+
securePairingCapable = pendingSecurePairingCapable,
|
|
1415
|
+
)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1044
1418
|
var seenDevices: MutableSet<String> = HashSet()
|
|
1045
1419
|
|
|
1046
1420
|
/** BLE Scan callback */
|
|
@@ -1093,42 +1467,78 @@ class MentraLive : SGCManager() {
|
|
|
1093
1467
|
deviceName.startsWith("MENTRA_LIVE_BT") ||
|
|
1094
1468
|
deviceName.lowercase().startsWith("mentra_live")
|
|
1095
1469
|
) {
|
|
1470
|
+
// Connect-by-name only when reconnecting OR connectById() asked for
|
|
1471
|
+
// this name. A discovery-only pairing scan must not auto-connect just
|
|
1472
|
+
// because savedDeviceName still matches (spoof risk).
|
|
1473
|
+
val isNamedConnectTarget =
|
|
1474
|
+
savedDeviceName.isNotEmpty() &&
|
|
1475
|
+
savedDeviceName == deviceName &&
|
|
1476
|
+
(isReconnecting || explicitConnectByName)
|
|
1477
|
+
if (!isReconnecting &&
|
|
1478
|
+
advertisesPairingFlag(result) &&
|
|
1479
|
+
!isPairingDiscoverable(result)
|
|
1480
|
+
) {
|
|
1481
|
+
// Tell RN a nearby unit is advertising but not pairable. The scan
|
|
1482
|
+
// screen keeps it visible with pairing-mode guidance but blocks the
|
|
1483
|
+
// connection until a pairable advertisement arrives.
|
|
1484
|
+
Bridge.log(
|
|
1485
|
+
"LIVE: Nearby $deviceName is secure firmware not in pairing mode — exposing as non-pairable"
|
|
1486
|
+
)
|
|
1487
|
+
Bridge.sendDiscoveredDevice(
|
|
1488
|
+
DeviceTypes.LIVE,
|
|
1489
|
+
deviceName,
|
|
1490
|
+
deviceAddress,
|
|
1491
|
+
result.rssi,
|
|
1492
|
+
pairingMode = false,
|
|
1493
|
+
pairingCode = null,
|
|
1494
|
+
securePairingCapable = true,
|
|
1495
|
+
)
|
|
1496
|
+
return
|
|
1497
|
+
}
|
|
1096
1498
|
val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
|
|
1499
|
+
val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
|
|
1097
1500
|
Bridge.log(
|
|
1098
1501
|
"LIVE: Found compatible " +
|
|
1099
1502
|
glassType +
|
|
1100
1503
|
" glasses device: " +
|
|
1101
|
-
deviceName
|
|
1504
|
+
deviceName +
|
|
1505
|
+
if (secureCapable) " (pairingMode=$pairingMode)"
|
|
1506
|
+
else " (legacy firmware, pairable)"
|
|
1102
1507
|
)
|
|
1103
|
-
// EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
|
|
1104
|
-
// smartGlassesDevice.deviceModelName, deviceName));
|
|
1105
1508
|
Bridge.sendDiscoveredDevice(
|
|
1106
1509
|
DeviceTypes.LIVE,
|
|
1107
1510
|
deviceName,
|
|
1108
1511
|
deviceAddress,
|
|
1109
|
-
result.rssi
|
|
1512
|
+
result.rssi,
|
|
1513
|
+
// Field firmware has no pairing flag. Treat it as pairable so the
|
|
1514
|
+
// new Mentra App can still find old glasses.
|
|
1515
|
+
pairingMode = if (secureCapable) pairingMode else true,
|
|
1516
|
+
pairingCode = pairingCode,
|
|
1517
|
+
securePairingCapable = secureCapable,
|
|
1110
1518
|
)
|
|
1111
1519
|
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
if (
|
|
1520
|
+
// connectById / reconnect: user (or reconnect) asked for this name —
|
|
1521
|
+
// GATT-connect. Discovery-only scans never set explicitConnectByName.
|
|
1522
|
+
if (isNamedConnectTarget) {
|
|
1115
1523
|
Log.i(
|
|
1116
1524
|
TAG,
|
|
1117
|
-
"🔌 🎯
|
|
1525
|
+
"🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
|
|
1118
1526
|
deviceName +
|
|
1119
1527
|
" (Attempt #" +
|
|
1120
1528
|
reconnectAttempts +
|
|
1121
1529
|
")"
|
|
1122
1530
|
)
|
|
1123
1531
|
Bridge.log(
|
|
1124
|
-
"LIVE: 🔌 🎯 Found
|
|
1532
|
+
"LIVE: 🔌 🎯 Found target device by name, connecting: " +
|
|
1125
1533
|
deviceName +
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1534
|
+
(if (isReconnecting)
|
|
1535
|
+
" (Reconnect attempt #" +
|
|
1536
|
+
reconnectAttempts +
|
|
1537
|
+
")"
|
|
1538
|
+
else " (connectById)")
|
|
1129
1539
|
)
|
|
1130
1540
|
synchronized(connectionLock) {
|
|
1131
|
-
if (isConnected || isConnecting) {
|
|
1541
|
+
if (pairingYieldActive || isConnected || isConnecting) {
|
|
1132
1542
|
return
|
|
1133
1543
|
}
|
|
1134
1544
|
isConnecting = true
|
|
@@ -1136,6 +1546,7 @@ class MentraLive : SGCManager() {
|
|
|
1136
1546
|
stopScan()
|
|
1137
1547
|
emitStopScanEvent()
|
|
1138
1548
|
isReconnecting = false
|
|
1549
|
+
explicitConnectByName = false
|
|
1139
1550
|
connectToDevice(device)
|
|
1140
1551
|
}
|
|
1141
1552
|
}
|
|
@@ -1217,11 +1628,47 @@ class MentraLive : SGCManager() {
|
|
|
1217
1628
|
return false
|
|
1218
1629
|
}
|
|
1219
1630
|
|
|
1631
|
+
private fun beginPairingTiming(reason: String) {
|
|
1632
|
+
pairingTimingStartMs = SystemClock.elapsedRealtime()
|
|
1633
|
+
pairingTimingLastMs = pairingTimingStartMs
|
|
1634
|
+
pairingTimingActive = true
|
|
1635
|
+
Bridge.log("LIVE: PAIRING_TIMING begin reason=$reason sinceStartMs=0 deltaMs=0")
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
private fun markPairingTiming(checkpoint: String, detail: String = "") {
|
|
1639
|
+
if (!pairingTimingActive) {
|
|
1640
|
+
return
|
|
1641
|
+
}
|
|
1642
|
+
val now = SystemClock.elapsedRealtime()
|
|
1643
|
+
val sinceStart = now - pairingTimingStartMs
|
|
1644
|
+
val delta = now - pairingTimingLastMs
|
|
1645
|
+
pairingTimingLastMs = now
|
|
1646
|
+
val extra = if (detail.isEmpty()) "" else " $detail"
|
|
1647
|
+
Bridge.log(
|
|
1648
|
+
"LIVE: PAIRING_TIMING checkpoint=$checkpoint sinceStartMs=$sinceStart deltaMs=$delta$extra"
|
|
1649
|
+
)
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
private fun endPairingTiming(checkpoint: String, detail: String = "") {
|
|
1653
|
+
markPairingTiming(checkpoint, detail)
|
|
1654
|
+
pairingTimingActive = false
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1220
1657
|
/** Connect to a specific BLE device */
|
|
1221
1658
|
private fun connectToDevice(device: BluetoothDevice?) {
|
|
1222
1659
|
if (device == null) {
|
|
1223
1660
|
return
|
|
1224
1661
|
}
|
|
1662
|
+
if (pairingYieldActive) {
|
|
1663
|
+
Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
|
|
1664
|
+
isConnecting = false
|
|
1665
|
+
isReconnecting = false
|
|
1666
|
+
return
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
beginPairingTiming(
|
|
1670
|
+
"connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
|
|
1671
|
+
)
|
|
1225
1672
|
|
|
1226
1673
|
// Cancel any previous connection timeouts
|
|
1227
1674
|
if (connectionTimeoutRunnable != null) {
|
|
@@ -1321,6 +1768,85 @@ class MentraLive : SGCManager() {
|
|
|
1321
1768
|
// }
|
|
1322
1769
|
// }
|
|
1323
1770
|
|
|
1771
|
+
private fun enterPairingYield(windowMs: Long) {
|
|
1772
|
+
pairingYieldActive = true
|
|
1773
|
+
pairingYieldAwaitingReclaim = false
|
|
1774
|
+
isReconnecting = false
|
|
1775
|
+
isConnecting = false
|
|
1776
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
1777
|
+
if (connectionTimeoutRunnable != null) {
|
|
1778
|
+
connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
|
|
1779
|
+
connectionTimeoutRunnable = null
|
|
1780
|
+
}
|
|
1781
|
+
// Stop scans/reconnect work; keep default_wearable / Classic bond.
|
|
1782
|
+
if (isScanning) {
|
|
1783
|
+
stopScan()
|
|
1784
|
+
emitStopScanEvent()
|
|
1785
|
+
}
|
|
1786
|
+
// Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
|
|
1787
|
+
// Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
|
|
1788
|
+
val yieldingDevice = connectedDevice
|
|
1789
|
+
disconnectClassicProfiles(yieldingDevice)
|
|
1790
|
+
// closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
|
|
1791
|
+
// onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
|
|
1792
|
+
// Mirror a real disconnect for UI + session state, then tear GATT down.
|
|
1793
|
+
isConnected = false
|
|
1794
|
+
connectedDevice = null
|
|
1795
|
+
glassesReady = false
|
|
1796
|
+
glassesSessionId = null
|
|
1797
|
+
readinessCompletedThisBleSession = false
|
|
1798
|
+
glassesReadyReceived = false
|
|
1799
|
+
ctkdInitiatedThisGattSession = false
|
|
1800
|
+
audioConnected = false
|
|
1801
|
+
notificationsEnabled = false
|
|
1802
|
+
processSendQueueRunnable?.let { handler.removeCallbacks(it) }
|
|
1803
|
+
stopReadinessCheckLoop()
|
|
1804
|
+
stopHeartbeat()
|
|
1805
|
+
stopSignalStrengthPolling()
|
|
1806
|
+
stopMicBeat()
|
|
1807
|
+
closeL2capFileChannel()
|
|
1808
|
+
fileProcessingHandler.removeCallbacksAndMessages(null)
|
|
1809
|
+
clearFilePacketBuffer()
|
|
1810
|
+
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
1811
|
+
closeGattQuietly(true)
|
|
1812
|
+
// Stand down for the whole window. Probing GATT during yield races the new
|
|
1813
|
+
// phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
|
|
1814
|
+
val end =
|
|
1815
|
+
Runnable {
|
|
1816
|
+
Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
|
|
1817
|
+
pairingYieldActive = false
|
|
1818
|
+
pairingYieldAwaitingReclaim = true
|
|
1819
|
+
pairingYieldEndRunnable = null
|
|
1820
|
+
if (!isKilled && !isConnected && !isConnecting) {
|
|
1821
|
+
handleReconnection()
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
pairingYieldEndRunnable = end
|
|
1825
|
+
handler.postDelayed(end, windowMs)
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
fun isPairingYieldActive(): Boolean = pairingYieldActive
|
|
1829
|
+
|
|
1830
|
+
private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
|
|
1831
|
+
Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
|
|
1832
|
+
pairingYieldAwaitingReclaim = false
|
|
1833
|
+
pairingYieldActive = false
|
|
1834
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
1835
|
+
pairingYieldEndRunnable = null
|
|
1836
|
+
try {
|
|
1837
|
+
forget()
|
|
1838
|
+
} catch (e: Exception) {
|
|
1839
|
+
Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
|
|
1840
|
+
}
|
|
1841
|
+
DeviceStore.apply("bluetooth", "default_wearable", "")
|
|
1842
|
+
DeviceStore.apply("bluetooth", "device_name", "")
|
|
1843
|
+
DeviceStore.apply("bluetooth", "device_address", "")
|
|
1844
|
+
Bridge.saveSetting("default_wearable", "")
|
|
1845
|
+
Bridge.saveSetting("device_name", "")
|
|
1846
|
+
Bridge.saveSetting("device_address", "")
|
|
1847
|
+
Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1324
1850
|
/** Handle reconnection with exponential backoff */
|
|
1325
1851
|
private fun handleReconnection() {
|
|
1326
1852
|
// Don't attempt reconnection if we've been killed/forgotten
|
|
@@ -1330,6 +1856,20 @@ class MentraLive : SGCManager() {
|
|
|
1330
1856
|
return
|
|
1331
1857
|
}
|
|
1332
1858
|
|
|
1859
|
+
if (manualDiscoveryActive) {
|
|
1860
|
+
Bridge.log(
|
|
1861
|
+
"LIVE: 🔌 RECONNECT ABORTED - Pair Glasses discovery is active"
|
|
1862
|
+
)
|
|
1863
|
+
isReconnecting = false
|
|
1864
|
+
return
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (pairingYieldActive) {
|
|
1868
|
+
Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
|
|
1869
|
+
isReconnecting = false
|
|
1870
|
+
return
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1333
1873
|
if (isReconnecting) {
|
|
1334
1874
|
Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
|
|
1335
1875
|
return
|
|
@@ -1401,10 +1941,25 @@ class MentraLive : SGCManager() {
|
|
|
1401
1941
|
"ms)"
|
|
1402
1942
|
)
|
|
1403
1943
|
|
|
1404
|
-
// Schedule reconnection attempt
|
|
1944
|
+
// Schedule reconnection attempt. A later manual scan/connect invalidates this
|
|
1945
|
+
// callback so stale saved identity cannot hijack user-initiated discovery.
|
|
1946
|
+
val generation = reconnectGeneration
|
|
1405
1947
|
handler.postDelayed(
|
|
1406
1948
|
object : Runnable {
|
|
1407
1949
|
override fun run() {
|
|
1950
|
+
if (generation != reconnectGeneration) {
|
|
1951
|
+
Bridge.log(
|
|
1952
|
+
"LIVE: 🔌 RECONNECT ABORTED - superseded by manual discovery"
|
|
1953
|
+
)
|
|
1954
|
+
return
|
|
1955
|
+
}
|
|
1956
|
+
if (pairingYieldActive) {
|
|
1957
|
+
isReconnecting = false
|
|
1958
|
+
Bridge.log(
|
|
1959
|
+
"LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
|
|
1960
|
+
)
|
|
1961
|
+
return
|
|
1962
|
+
}
|
|
1408
1963
|
if (!isConnected && !isConnecting && !isKilled) {
|
|
1409
1964
|
// Prefer saved MAC for direct GATT connect (faster and more reliable
|
|
1410
1965
|
// than scanning).
|
|
@@ -1554,9 +2109,11 @@ class MentraLive : SGCManager() {
|
|
|
1554
2109
|
Bridge.log(
|
|
1555
2110
|
"LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
|
|
1556
2111
|
)
|
|
2112
|
+
markPairingTiming("gatt_connected")
|
|
1557
2113
|
isConnecting = false
|
|
1558
2114
|
isConnected = true
|
|
1559
2115
|
connectedDevice = gatt.device
|
|
2116
|
+
emitConnectedPendingDeviceForPairingScan()
|
|
1560
2117
|
|
|
1561
2118
|
DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
|
|
1562
2119
|
// Persist MAC so reconnection can use direct GATT instead of scanning
|
|
@@ -1581,42 +2138,27 @@ class MentraLive : SGCManager() {
|
|
|
1581
2138
|
// connectedDevice.getName());
|
|
1582
2139
|
// }
|
|
1583
2140
|
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
2141
|
+
// Register early so we don't miss bond broadcasts, but defer
|
|
2142
|
+
// createBond / A2DP until BLE GATT setup finishes (see
|
|
2143
|
+
// maybeInitiateCtkdAudioAfterBleReady).
|
|
1586
2144
|
registerBondingReceiver()
|
|
1587
|
-
bondingRetryCount = 0
|
|
2145
|
+
bondingRetryCount = 0
|
|
2146
|
+
ctkdInitiatedThisGattSession = false
|
|
1588
2147
|
Bridge.log(
|
|
1589
|
-
"LIVE: CTKD: BLE
|
|
2148
|
+
"LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
|
|
2149
|
+
)
|
|
2150
|
+
markPairingTiming(
|
|
2151
|
+
"ctkd_deferred",
|
|
2152
|
+
"bondState=${connectedDevice!!.bondState}"
|
|
1590
2153
|
)
|
|
1591
|
-
|
|
1592
|
-
// Check if device is already bonded before attempting to create bond
|
|
1593
|
-
if (connectedDevice!!.bondState == BluetoothDevice.BOND_BONDED) {
|
|
1594
|
-
Bridge.log(
|
|
1595
|
-
"LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
|
|
1596
|
-
)
|
|
1597
|
-
// Device is bonded but we need to explicitly connect the A2DP audio
|
|
1598
|
-
// profile
|
|
1599
|
-
// Just being bonded doesn't mean the audio profile is connected
|
|
1600
|
-
connectA2dpProfile(connectedDevice!!)
|
|
1601
|
-
// Note: audioConnected will be set to true once A2DP profile
|
|
1602
|
-
// connects
|
|
1603
|
-
} else {
|
|
1604
|
-
createBond(connectedDevice!!)
|
|
1605
|
-
}
|
|
1606
2154
|
|
|
1607
2155
|
// Discover services
|
|
1608
2156
|
gatt.discoverServices()
|
|
1609
2157
|
|
|
1610
|
-
//
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
isReconnecting = false
|
|
1614
|
-
Log.i(
|
|
1615
|
-
TAG,
|
|
1616
|
-
"🔌 ✅ Reconnection counter reset (was at " +
|
|
1617
|
-
previousAttempts +
|
|
1618
|
-
" attempts)"
|
|
1619
|
-
)
|
|
2158
|
+
// Do NOT reset reconnectAttempts here — ephemeral GATT CONNECTED
|
|
2159
|
+
// followed by status 19 was zeroing the counter every ~1s and
|
|
2160
|
+
// preventing exponential backoff. Reset at ble_chars_ready instead.
|
|
2161
|
+
isReconnecting = false
|
|
1620
2162
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
1621
2163
|
Log.w(
|
|
1622
2164
|
TAG,
|
|
@@ -1625,6 +2167,10 @@ class MentraLive : SGCManager() {
|
|
|
1625
2167
|
Bridge.log(
|
|
1626
2168
|
"LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
|
|
1627
2169
|
)
|
|
2170
|
+
endPairingTiming(
|
|
2171
|
+
"gatt_disconnected",
|
|
2172
|
+
"queueSize=${sendQueue.size}"
|
|
2173
|
+
)
|
|
1628
2174
|
isConnected = false
|
|
1629
2175
|
isConnecting = false
|
|
1630
2176
|
|
|
@@ -1635,6 +2181,7 @@ class MentraLive : SGCManager() {
|
|
|
1635
2181
|
|
|
1636
2182
|
// Reset audio pairing flags
|
|
1637
2183
|
glassesReadyReceived = false
|
|
2184
|
+
ctkdInitiatedThisGattSession = false
|
|
1638
2185
|
audioConnected = false
|
|
1639
2186
|
|
|
1640
2187
|
notificationsEnabled = false
|
|
@@ -1693,6 +2240,10 @@ class MentraLive : SGCManager() {
|
|
|
1693
2240
|
status +
|
|
1694
2241
|
") - Will retry reconnection"
|
|
1695
2242
|
)
|
|
2243
|
+
endPairingTiming(
|
|
2244
|
+
"gatt_error",
|
|
2245
|
+
"status=$status queueSize=${sendQueue.size}"
|
|
2246
|
+
)
|
|
1696
2247
|
isConnected = false
|
|
1697
2248
|
isConnecting = false
|
|
1698
2249
|
glassesReady = false
|
|
@@ -1700,6 +2251,7 @@ class MentraLive : SGCManager() {
|
|
|
1700
2251
|
readinessCompletedThisBleSession = false
|
|
1701
2252
|
glassesReadyReceived = false
|
|
1702
2253
|
audioConnected = false
|
|
2254
|
+
ctkdInitiatedThisGattSession = false
|
|
1703
2255
|
|
|
1704
2256
|
notificationsEnabled = false
|
|
1705
2257
|
|
|
@@ -1723,6 +2275,19 @@ class MentraLive : SGCManager() {
|
|
|
1723
2275
|
// Clean up resources
|
|
1724
2276
|
closeGattQuietly(false)
|
|
1725
2277
|
|
|
2278
|
+
// Auth/not-owner after pairing yield — not a generic RF blip.
|
|
2279
|
+
// GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
|
|
2280
|
+
// not wipe the saved owner.
|
|
2281
|
+
if (pairingYieldAwaitingReclaim &&
|
|
2282
|
+
(status == 5 ||
|
|
2283
|
+
status == 15 ||
|
|
2284
|
+
status == 0x05 ||
|
|
2285
|
+
status == 0x0F)
|
|
2286
|
+
) {
|
|
2287
|
+
clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
|
|
2288
|
+
return
|
|
2289
|
+
}
|
|
2290
|
+
|
|
1726
2291
|
// Attempt reconnection if not killed
|
|
1727
2292
|
if (!isKilled) {
|
|
1728
2293
|
Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
|
|
@@ -1773,24 +2338,47 @@ class MentraLive : SGCManager() {
|
|
|
1773
2338
|
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
|
|
1774
2339
|
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
|
|
1775
2340
|
|
|
1776
|
-
//
|
|
2341
|
+
// LC3 audio is optional. Older firmware can still run the core session
|
|
2342
|
+
// and receive an OTA without these characteristics.
|
|
1777
2343
|
lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
|
|
1778
2344
|
lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
|
|
1779
2345
|
|
|
1780
|
-
//
|
|
2346
|
+
// Match iOS: only the core transport pair is required for readiness.
|
|
1781
2347
|
val hasRequiredCharacteristics =
|
|
1782
|
-
(
|
|
1783
|
-
|
|
1784
|
-
|
|
2348
|
+
hasRequiredMentraLiveCoreCharacteristics(
|
|
2349
|
+
hasRxCharacteristic = rxCharacteristic != null,
|
|
2350
|
+
hasTxCharacteristic = txCharacteristic != null,
|
|
2351
|
+
)
|
|
2352
|
+
val hasLc3AudioCharacteristics =
|
|
2353
|
+
lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
|
|
1785
2354
|
|
|
1786
2355
|
if (hasRequiredCharacteristics) {
|
|
2356
|
+
if (!hasLc3AudioCharacteristics) {
|
|
2357
|
+
Bridge.log(
|
|
2358
|
+
"LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
|
|
2359
|
+
)
|
|
2360
|
+
}
|
|
1787
2361
|
// BLE connection established, but we still need to wait for glasses
|
|
1788
2362
|
// SOC
|
|
1789
2363
|
Bridge.log(
|
|
1790
|
-
"LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX
|
|
2364
|
+
"LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
|
|
2365
|
+
hasLc3AudioCharacteristics +
|
|
2366
|
+
")"
|
|
1791
2367
|
)
|
|
2368
|
+
markPairingTiming("ble_chars_ready")
|
|
1792
2369
|
Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
|
|
1793
2370
|
|
|
2371
|
+
// GATT stack reached real readiness — safe to clear reconnect
|
|
2372
|
+
// backoff.
|
|
2373
|
+
val previousAttempts = reconnectAttempts
|
|
2374
|
+
reconnectAttempts = 0
|
|
2375
|
+
Log.i(
|
|
2376
|
+
TAG,
|
|
2377
|
+
"🔌 ✅ Reconnection counter reset at ble_chars_ready (was at " +
|
|
2378
|
+
previousAttempts +
|
|
2379
|
+
" attempts)"
|
|
2380
|
+
)
|
|
2381
|
+
|
|
1794
2382
|
// Don't set connected=true here - wait for SOC to be ready
|
|
1795
2383
|
// (fullyBooted=true)
|
|
1796
2384
|
// DeviceStore handles connected state based on fullyBooted
|
|
@@ -1831,13 +2419,6 @@ class MentraLive : SGCManager() {
|
|
|
1831
2419
|
if (txCharacteristic == null) {
|
|
1832
2420
|
Log.e(TAG, "TX characteristic (peripheral's RX) not found")
|
|
1833
2421
|
}
|
|
1834
|
-
// Log LC3 characteristic errors
|
|
1835
|
-
if (lc3ReadCharacteristic == null) {
|
|
1836
|
-
Log.e(TAG, "LC3_READ characteristic not found")
|
|
1837
|
-
}
|
|
1838
|
-
if (lc3WriteCharacteristic == null) {
|
|
1839
|
-
Log.e(TAG, "LC3_WRITE characteristic not found")
|
|
1840
|
-
}
|
|
1841
2422
|
gatt.disconnect()
|
|
1842
2423
|
}
|
|
1843
2424
|
} else {
|
|
@@ -1944,6 +2525,10 @@ class MentraLive : SGCManager() {
|
|
|
1944
2525
|
gatt: BluetoothGatt,
|
|
1945
2526
|
characteristic: BluetoothGattCharacteristic
|
|
1946
2527
|
) {
|
|
2528
|
+
if (!isCurrentGattCallback(gatt)) {
|
|
2529
|
+
return
|
|
2530
|
+
}
|
|
2531
|
+
|
|
1947
2532
|
// Get thread ID for tracking thread issues
|
|
1948
2533
|
val threadId = Thread.currentThread().id
|
|
1949
2534
|
val uuid = characteristic.uuid
|
|
@@ -1977,20 +2562,6 @@ class MentraLive : SGCManager() {
|
|
|
1977
2562
|
|
|
1978
2563
|
if (isRxCharacteristic) {
|
|
1979
2564
|
Bridge.log("LIVE: Received data on RX characteristic")
|
|
1980
|
-
// #region agent log [810da2] Hypothesis A+C: capture data.length vs
|
|
1981
|
-
// negotiated MTU
|
|
1982
|
-
Bridge.log(
|
|
1983
|
-
"LIVE: [DEBUG-810da2-HypAC] RX dataLen=" +
|
|
1984
|
-
data.size +
|
|
1985
|
-
" mtu=" +
|
|
1986
|
-
currentMtu +
|
|
1987
|
-
" firstByte=0x" +
|
|
1988
|
-
String.format("%02X", data[0]) +
|
|
1989
|
-
" second=0x" +
|
|
1990
|
-
(if (data.size > 1) String.format("%02X", data[1])
|
|
1991
|
-
else "??")
|
|
1992
|
-
)
|
|
1993
|
-
// #endregion
|
|
1994
2565
|
} else if (isTxCharacteristic) {
|
|
1995
2566
|
Bridge.log("LIVE: Received data on TX characteristic")
|
|
1996
2567
|
} else if (isLc3ReadCharacteristic) {
|
|
@@ -2095,6 +2666,9 @@ class MentraLive : SGCManager() {
|
|
|
2095
2666
|
startSignalStrengthPolling()
|
|
2096
2667
|
handler.post(processSendQueueRunnable!!)
|
|
2097
2668
|
startReadinessCheckLoop()
|
|
2669
|
+
// Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
|
|
2670
|
+
// collide during service discovery / descriptor writes.
|
|
2671
|
+
maybeInitiateCtkdAudioAfterBleReady()
|
|
2098
2672
|
return
|
|
2099
2673
|
}
|
|
2100
2674
|
|
|
@@ -3068,25 +3642,6 @@ class MentraLive : SGCManager() {
|
|
|
3068
3642
|
processJsonMessage(expanded)
|
|
3069
3643
|
} else {
|
|
3070
3644
|
Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
|
|
3071
|
-
// #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
|
|
3072
|
-
// data length
|
|
3073
|
-
val declaredPayloadLen =
|
|
3074
|
-
if (data.size >= 5)
|
|
3075
|
-
(((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF))
|
|
3076
|
-
else -1
|
|
3077
|
-
Bridge.log(
|
|
3078
|
-
"LIVE: [DEBUG-810da2-HypAB] K900 PARSE FAILED thread=" +
|
|
3079
|
-
threadId +
|
|
3080
|
-
" dataLen=" +
|
|
3081
|
-
data.size +
|
|
3082
|
-
" mtu=" +
|
|
3083
|
-
currentMtu +
|
|
3084
|
-
" declaredPayloadLen=" +
|
|
3085
|
-
declaredPayloadLen +
|
|
3086
|
-
" expectedTotal=" +
|
|
3087
|
-
(declaredPayloadLen + 7)
|
|
3088
|
-
)
|
|
3089
|
-
// #endregion
|
|
3090
3645
|
}
|
|
3091
3646
|
|
|
3092
3647
|
return // Exit after processing K900 protocol format
|
|
@@ -3260,6 +3815,24 @@ class MentraLive : SGCManager() {
|
|
|
3260
3815
|
"pong" ->
|
|
3261
3816
|
// Process heartbeat pong response
|
|
3262
3817
|
Bridge.log("LIVE: Received pong response - connection healthy")
|
|
3818
|
+
"entering_pairing_mode" -> {
|
|
3819
|
+
val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
|
|
3820
|
+
Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
|
|
3821
|
+
enterPairingYield(windowMs)
|
|
3822
|
+
val body = HashMap<String, Any>()
|
|
3823
|
+
body["window_ms"] = windowMs
|
|
3824
|
+
body["reason"] = json.optString("reason", "user_gesture")
|
|
3825
|
+
Bridge.sendTypedMessage("entering_pairing_mode", body)
|
|
3826
|
+
}
|
|
3827
|
+
"pairing_info" ->
|
|
3828
|
+
Bridge.sendPairingInfo(
|
|
3829
|
+
json.optBoolean("had_previous_bond", false),
|
|
3830
|
+
jsonNullableString(json, "pairing_code"),
|
|
3831
|
+
json.optBoolean("classic_bond_ready", false),
|
|
3832
|
+
// Legacy firmware that omits this field is not secure-capable.
|
|
3833
|
+
json.optBoolean("secure_pairing_capable", false),
|
|
3834
|
+
json.optInt("protocol_version", 1),
|
|
3835
|
+
)
|
|
3263
3836
|
"imu_response",
|
|
3264
3837
|
"imu_stream_response",
|
|
3265
3838
|
"imu_gesture_response",
|
|
@@ -3648,7 +4221,19 @@ class MentraLive : SGCManager() {
|
|
|
3648
4221
|
}
|
|
3649
4222
|
"glasses_ready" -> {
|
|
3650
4223
|
// Glasses SOC has booted and is ready for communication
|
|
4224
|
+
if (pairingYieldActive && !isConnected) {
|
|
4225
|
+
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
|
|
4226
|
+
return
|
|
4227
|
+
}
|
|
3651
4228
|
Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
|
|
4229
|
+
pairingYieldAwaitingReclaim = false
|
|
4230
|
+
pairingYieldActive = false
|
|
4231
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
4232
|
+
pairingYieldEndRunnable = null
|
|
4233
|
+
markPairingTiming(
|
|
4234
|
+
"glasses_ready",
|
|
4235
|
+
"audioConnected=$audioConnected queueSize=${sendQueue.size}"
|
|
4236
|
+
)
|
|
3652
4237
|
|
|
3653
4238
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
3654
4239
|
// onTransportReset() before sending it, so their side is back on legacy
|
|
@@ -3667,9 +4252,9 @@ class MentraLive : SGCManager() {
|
|
|
3667
4252
|
// Set the ready flag to stop any future readiness checks
|
|
3668
4253
|
glassesReady = true
|
|
3669
4254
|
glassesReadyReceived = true
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
//
|
|
4255
|
+
// Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
|
|
4256
|
+
// fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
|
|
4257
|
+
// for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
|
|
3673
4258
|
|
|
3674
4259
|
// Stop the readiness check loop since we got confirmation
|
|
3675
4260
|
stopReadinessCheckLoop()
|
|
@@ -3789,19 +4374,21 @@ class MentraLive : SGCManager() {
|
|
|
3789
4374
|
Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
|
|
3790
4375
|
}
|
|
3791
4376
|
|
|
3792
|
-
//
|
|
3793
|
-
//
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
4377
|
+
// Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
|
|
4378
|
+
// Do not wait for CTKD here — iOS already marks ready on glasses_ready alone.
|
|
4379
|
+
endPairingTiming(
|
|
4380
|
+
"fully_connected",
|
|
4381
|
+
"via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
|
|
4382
|
+
)
|
|
4383
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
4384
|
+
updateConnectionState(ConnTypes.CONNECTED)
|
|
4385
|
+
if (!audioConnected) {
|
|
3797
4386
|
Bridge.log(
|
|
3798
|
-
"LIVE: Audio:
|
|
4387
|
+
"LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
|
|
3799
4388
|
)
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
Bridge.log(
|
|
3804
|
-
"LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"
|
|
4389
|
+
markPairingTiming(
|
|
4390
|
+
"waiting_ctkd_bond",
|
|
4391
|
+
"queueSize=${sendQueue.size}"
|
|
3805
4392
|
)
|
|
3806
4393
|
}
|
|
3807
4394
|
}
|
|
@@ -4115,6 +4702,9 @@ class MentraLive : SGCManager() {
|
|
|
4115
4702
|
(fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4116
4703
|
DeviceStore.apply("glasses", "bluetoothMacAddress", it)
|
|
4117
4704
|
}
|
|
4705
|
+
(fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4706
|
+
DeviceStore.apply("glasses", "wifiMacAddress", it)
|
|
4707
|
+
}
|
|
4118
4708
|
(fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4119
4709
|
DeviceStore.apply("glasses", "serialNumber", it)
|
|
4120
4710
|
}
|
|
@@ -4124,6 +4714,12 @@ class MentraLive : SGCManager() {
|
|
|
4124
4714
|
DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
|
|
4125
4715
|
}
|
|
4126
4716
|
}
|
|
4717
|
+
if (fields.containsKey("hotspot_ota_version")) {
|
|
4718
|
+
val version = fields["hotspot_ota_version"]
|
|
4719
|
+
if (version is Number) {
|
|
4720
|
+
DeviceStore.apply("glasses", "hotspotOtaVersion", version.toInt())
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4127
4723
|
|
|
4128
4724
|
// Negotiate wire caps from ANY version_info chunk, not only the one
|
|
4129
4725
|
// carrying build_number: the glasses put wire_caps in version_info_3
|
|
@@ -4480,6 +5076,10 @@ class MentraLive : SGCManager() {
|
|
|
4480
5076
|
}
|
|
4481
5077
|
if (ready == 1) {
|
|
4482
5078
|
Bridge.log("LIVE: K900 SOC ready")
|
|
5079
|
+
markPairingTiming(
|
|
5080
|
+
"soc_ready",
|
|
5081
|
+
"glassesReady=$glassesReady queueSize=${sendQueue.size}"
|
|
5082
|
+
)
|
|
4483
5083
|
// Only send phone_ready if we haven't already established connection
|
|
4484
5084
|
// This prevents re-initialization on every heartbeat after initial
|
|
4485
5085
|
// connection
|
|
@@ -5187,6 +5787,8 @@ class MentraLive : SGCManager() {
|
|
|
5187
5787
|
|
|
5188
5788
|
/** Start the micbeat mechanism - periodically enable custom audio TX */
|
|
5189
5789
|
private fun startMicBeat() {
|
|
5790
|
+
// A resumed custom-audio stream starts a new sequence domain.
|
|
5791
|
+
DeviceManager.getInstance().resetMicSequenceBaseline()
|
|
5190
5792
|
micOnCount++
|
|
5191
5793
|
Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
|
|
5192
5794
|
micBeatCount = 0
|
|
@@ -5328,8 +5930,16 @@ class MentraLive : SGCManager() {
|
|
|
5328
5930
|
override fun findCompatibleDevices() {
|
|
5329
5931
|
Bridge.log("LIVE: Finding compatible Mentra Live glasses")
|
|
5330
5932
|
|
|
5331
|
-
//
|
|
5933
|
+
// A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
|
|
5934
|
+
// and replace any active fast reconnect scan with discovery mode.
|
|
5935
|
+
reconnectGeneration++
|
|
5936
|
+
if (isScanning) {
|
|
5937
|
+
stopScan()
|
|
5938
|
+
}
|
|
5939
|
+
manualDiscoveryActive = true
|
|
5332
5940
|
isReconnecting = false
|
|
5941
|
+
// Discovery-only: do not treat leftover savedDeviceName as a connect target.
|
|
5942
|
+
explicitConnectByName = false
|
|
5333
5943
|
|
|
5334
5944
|
if (bluetoothAdapter == null) {
|
|
5335
5945
|
Log.e(TAG, "Bluetooth not available")
|
|
@@ -5341,50 +5951,124 @@ class MentraLive : SGCManager() {
|
|
|
5341
5951
|
return
|
|
5342
5952
|
}
|
|
5343
5953
|
|
|
5954
|
+
// Stale Classic ACL keeps Mentra Live from advertising — drop A2DP/HFP (keep bond)
|
|
5955
|
+
// so a pairing/discovery scan can see the unit again. Keep the active session's
|
|
5956
|
+
// audio; tear down other bonded Lives so a second unit can advertise.
|
|
5957
|
+
releaseStaleClassicForDiscovery()
|
|
5958
|
+
|
|
5344
5959
|
// Start scanning for BLE devices
|
|
5345
5960
|
startScan()
|
|
5961
|
+
handler.post { emitConnectedPendingDeviceForPairingScan() }
|
|
5962
|
+
}
|
|
5963
|
+
|
|
5964
|
+
private fun releaseStaleClassicForDiscovery() {
|
|
5965
|
+
val adapter = bluetoothAdapter ?: return
|
|
5966
|
+
val bonded =
|
|
5967
|
+
try {
|
|
5968
|
+
adapter.bondedDevices?.toList().orEmpty()
|
|
5969
|
+
} catch (e: Exception) {
|
|
5970
|
+
Bridge.log("LIVE: Classic: discovery release — cannot list bonded: " + e.message)
|
|
5971
|
+
return
|
|
5972
|
+
}
|
|
5973
|
+
val mentraBonded =
|
|
5974
|
+
bonded.filter { isMentraLiveBluetoothName(safeDeviceName(it)) }
|
|
5975
|
+
if (mentraBonded.isEmpty()) {
|
|
5976
|
+
return
|
|
5977
|
+
}
|
|
5978
|
+
val currentAddress = connectedDevice?.address
|
|
5979
|
+
for (device in mentraBonded) {
|
|
5980
|
+
if (currentAddress != null && device.address.equals(currentAddress, ignoreCase = true)) {
|
|
5981
|
+
continue
|
|
5982
|
+
}
|
|
5983
|
+
Bridge.log(
|
|
5984
|
+
"LIVE: Classic: releasing stale A2DP/HFP before discovery for " +
|
|
5985
|
+
safeDeviceName(device) +
|
|
5986
|
+
" (" +
|
|
5987
|
+
device.address +
|
|
5988
|
+
")"
|
|
5989
|
+
)
|
|
5990
|
+
disconnectClassicProfiles(device)
|
|
5991
|
+
}
|
|
5346
5992
|
}
|
|
5347
5993
|
|
|
5348
5994
|
override fun connectById(id: String) {
|
|
5995
|
+
if (pairingYieldActive) {
|
|
5996
|
+
Bridge.log("LIVE: connectById blocked — pairing yield active")
|
|
5997
|
+
return
|
|
5998
|
+
}
|
|
5999
|
+
reconnectGeneration++
|
|
6000
|
+
manualDiscoveryActive = false
|
|
6001
|
+
if (isScanning) {
|
|
6002
|
+
stopScan()
|
|
6003
|
+
}
|
|
6004
|
+
isReconnecting = false
|
|
5349
6005
|
Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
|
|
5350
6006
|
savedDeviceName = id
|
|
5351
|
-
//
|
|
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());
|
|
6007
|
+
// Without this, connectToSmartGlasses() falls back to a name scan with
|
|
6008
|
+
// isReconnecting=false and never GATT-connects (loading screen hangs).
|
|
6009
|
+
explicitConnectByName = true
|
|
5358
6010
|
connectToSmartGlasses()
|
|
5359
6011
|
}
|
|
5360
6012
|
|
|
6013
|
+
/**
|
|
6014
|
+
* True while Android is showing (or about to show) the system pairing dialog for
|
|
6015
|
+
* CTKD Classic bonding. Calling [removeBond] / tearing down the SGC here aborts that
|
|
6016
|
+
* dialog and forces a full re-pair.
|
|
6017
|
+
*/
|
|
6018
|
+
fun isCtkdBondingInProgress(): Boolean {
|
|
6019
|
+
val device = connectedDevice ?: return false
|
|
6020
|
+
return device.bondState == BluetoothDevice.BOND_BONDING
|
|
6021
|
+
}
|
|
6022
|
+
|
|
5361
6023
|
override fun forget() {
|
|
5362
6024
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
5363
6025
|
|
|
5364
|
-
// Clear saved device name
|
|
5365
|
-
//
|
|
5366
|
-
|
|
5367
|
-
// Bridge.log("LIVE: Cleared saved device name");
|
|
6026
|
+
// Clear saved device name so a leftover name can't bypass the pairing-mode
|
|
6027
|
+
// discovery filter for this unit on a subsequent rescan (parity with iOS forget()).
|
|
6028
|
+
savedDeviceName = ""
|
|
5368
6029
|
|
|
5369
6030
|
// Reset reconnection state
|
|
5370
6031
|
reconnectAttempts = 0
|
|
5371
6032
|
isReconnecting = false
|
|
6033
|
+
manualDiscoveryActive = false
|
|
6034
|
+
explicitConnectByName = false
|
|
5372
6035
|
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
if (connectedDevice != null) {
|
|
5376
|
-
Bridge.log("LIVE: CTKD: Removing BT bond on explicit unpair")
|
|
5377
|
-
removeBond(connectedDevice!!)
|
|
5378
|
-
}
|
|
5379
|
-
|
|
6036
|
+
val ctx = context ?: Bridge.getContext()
|
|
6037
|
+
val bondedAddress = connectedDevice?.address
|
|
5380
6038
|
if (isScanning) {
|
|
5381
6039
|
stopScan()
|
|
5382
6040
|
emitStopScanEvent()
|
|
5383
6041
|
}
|
|
5384
|
-
|
|
6042
|
+
// Tell glasses to drop Mentra owner before tearing the link. removeBond
|
|
6043
|
+
// alone leaves glasses NV owned, so the next pair hits OWNER_ONLY.
|
|
6044
|
+
try {
|
|
6045
|
+
val unpair = JSONObject()
|
|
6046
|
+
unpair.put("type", "unpair")
|
|
6047
|
+
sendJsonWithoutAck(unpair, true)
|
|
6048
|
+
} catch (e: JSONException) {
|
|
6049
|
+
Log.e(TAG, "Error creating unpair command", e)
|
|
6050
|
+
}
|
|
6051
|
+
// Give the unpair write a beat to leave GATT before destroy() cancels the queue.
|
|
6052
|
+
unpairFlushRunnable?.let { handler.removeCallbacks(it) }
|
|
6053
|
+
unpairFlushPending = true
|
|
6054
|
+
val flush =
|
|
6055
|
+
Runnable {
|
|
6056
|
+
unpairFlushPending = false
|
|
6057
|
+
unpairFlushRunnable = null
|
|
6058
|
+
// Drop GATT first. removeBond while the BLE link is still encrypted often
|
|
6059
|
+
// returns true on Samsung but the OS Bluetooth UI keeps the dual-mode pair.
|
|
6060
|
+
disconnect()
|
|
6061
|
+
unbondBondedMentraLiveDevices(ctx, bondedAddress)
|
|
6062
|
+
}
|
|
6063
|
+
unpairFlushRunnable = flush
|
|
6064
|
+
handler.postDelayed(flush, UNPAIR_FLUSH_MS)
|
|
5385
6065
|
}
|
|
5386
6066
|
|
|
5387
6067
|
override fun disconnect() {
|
|
6068
|
+
if (unpairFlushPending) {
|
|
6069
|
+
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
6070
|
+
return
|
|
6071
|
+
}
|
|
5388
6072
|
Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
|
|
5389
6073
|
destroy()
|
|
5390
6074
|
}
|
|
@@ -5422,6 +6106,10 @@ class MentraLive : SGCManager() {
|
|
|
5422
6106
|
}
|
|
5423
6107
|
|
|
5424
6108
|
fun connectToSmartGlasses() {
|
|
6109
|
+
if (pairingYieldActive) {
|
|
6110
|
+
Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
|
|
6111
|
+
return
|
|
6112
|
+
}
|
|
5425
6113
|
Bridge.log("LIVE: Connecting to Mentra Live glasses")
|
|
5426
6114
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
5427
6115
|
|
|
@@ -5450,7 +6138,13 @@ class MentraLive : SGCManager() {
|
|
|
5450
6138
|
// var context = Bridge.getContext();
|
|
5451
6139
|
// SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
5452
6140
|
// String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
|
|
5453
|
-
val
|
|
6141
|
+
val pendingAddress =
|
|
6142
|
+
(DeviceStore.get("bluetooth", "pending_device_address") as String?)?.takeIf {
|
|
6143
|
+
it.isNotEmpty()
|
|
6144
|
+
}
|
|
6145
|
+
val lastDeviceAddress =
|
|
6146
|
+
pendingAddress
|
|
6147
|
+
?: (DeviceStore.get("bluetooth", "device_address") as String?)
|
|
5454
6148
|
|
|
5455
6149
|
if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
|
|
5456
6150
|
// Connect to last known device if available
|
|
@@ -5461,6 +6155,7 @@ class MentraLive : SGCManager() {
|
|
|
5461
6155
|
Bridge.log(
|
|
5462
6156
|
"LIVE: Found saved device, connecting directly: " + lastDeviceAddress
|
|
5463
6157
|
)
|
|
6158
|
+
prepareClassicBeforeBleConnect(device)
|
|
5464
6159
|
connectToDevice(device)
|
|
5465
6160
|
} else {
|
|
5466
6161
|
Bridge.log(
|
|
@@ -5476,12 +6171,62 @@ class MentraLive : SGCManager() {
|
|
|
5476
6171
|
startScan() // Fallback to scanning
|
|
5477
6172
|
}
|
|
5478
6173
|
} else {
|
|
5479
|
-
//
|
|
5480
|
-
|
|
5481
|
-
|
|
6174
|
+
// Pairing may only have a name (or address wasn't stashed). Prefer a bonded
|
|
6175
|
+
// device match over scan — Classic-connected glasses often stop advertising.
|
|
6176
|
+
val bonded = findBondedDeviceByName(savedDeviceName)
|
|
6177
|
+
if (bonded != null) {
|
|
6178
|
+
Bridge.log(
|
|
6179
|
+
"LIVE: No saved address — using bonded device " +
|
|
6180
|
+
safeDeviceName(bonded) +
|
|
6181
|
+
" (" +
|
|
6182
|
+
bonded.address +
|
|
6183
|
+
")"
|
|
6184
|
+
)
|
|
6185
|
+
prepareClassicBeforeBleConnect(bonded)
|
|
6186
|
+
connectToDevice(bonded)
|
|
6187
|
+
} else {
|
|
6188
|
+
Bridge.log("LIVE: No last known device, starting scan")
|
|
6189
|
+
startScan()
|
|
6190
|
+
}
|
|
5482
6191
|
}
|
|
5483
6192
|
}
|
|
5484
6193
|
|
|
6194
|
+
private fun findBondedDeviceByName(name: String): BluetoothDevice? {
|
|
6195
|
+
if (name.isEmpty() || bluetoothAdapter == null) {
|
|
6196
|
+
return null
|
|
6197
|
+
}
|
|
6198
|
+
return try {
|
|
6199
|
+
bluetoothAdapter!!.bondedDevices?.firstOrNull { safeDeviceName(it) == name }
|
|
6200
|
+
} catch (e: SecurityException) {
|
|
6201
|
+
Bridge.log("LIVE: Unable to read bonded devices: " + e.message)
|
|
6202
|
+
null
|
|
6203
|
+
} catch (e: Exception) {
|
|
6204
|
+
Bridge.log("LIVE: Error reading bonded devices: " + e.message)
|
|
6205
|
+
null
|
|
6206
|
+
}
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6209
|
+
/**
|
|
6210
|
+
* Fresh pairing / discovery may need Classic down so glasses leave a stuck ACL.
|
|
6211
|
+
* Owner reconnect must NOT tear Classic first: BES BLE-PAIR-GUARD historically
|
|
6212
|
+
* admitted Mentra App RPAs only while Classic owner ACL was up — tearing it
|
|
6213
|
+
* caused GATT status=19 reject loops after Mentra App disconnect.
|
|
6214
|
+
*/
|
|
6215
|
+
private fun prepareClassicBeforeBleConnect(device: BluetoothDevice) {
|
|
6216
|
+
if (device.bondState != BluetoothDevice.BOND_BONDED) {
|
|
6217
|
+
return
|
|
6218
|
+
}
|
|
6219
|
+
// Owner reconnect and connectById both go through connectToSmartGlasses(),
|
|
6220
|
+
// which clears isReconnecting first. Leave Classic up: tearing A2DP/HFP
|
|
6221
|
+
// here historically caused GATT status=19 reject loops. Discovery teardown
|
|
6222
|
+
// lives in releaseStaleClassicForDiscovery() for pairing scans only.
|
|
6223
|
+
Bridge.log(
|
|
6224
|
+
"LIVE: Classic: bonded target — leave A2DP/HFP up for BLE admit (" +
|
|
6225
|
+
safeDeviceName(device) +
|
|
6226
|
+
")"
|
|
6227
|
+
)
|
|
6228
|
+
}
|
|
6229
|
+
|
|
5485
6230
|
override fun setMicEnabled(enabled: Boolean) {
|
|
5486
6231
|
Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
|
|
5487
6232
|
|
|
@@ -5925,41 +6670,67 @@ class MentraLive : SGCManager() {
|
|
|
5925
6670
|
Bridge.log(
|
|
5926
6671
|
"LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
|
|
5927
6672
|
)
|
|
6673
|
+
markPairingTiming(
|
|
6674
|
+
"ctkd_bonded",
|
|
6675
|
+
"glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
|
|
6676
|
+
)
|
|
5928
6677
|
if (isKilled) {
|
|
5929
6678
|
Bridge.log(
|
|
5930
6679
|
"LIVE: CTKD: Ignoring bond complete — SGC destroyed"
|
|
5931
6680
|
)
|
|
5932
6681
|
return@run
|
|
5933
6682
|
}
|
|
5934
|
-
isBtClassicConnected = true
|
|
5935
|
-
audioConnected = true
|
|
5936
6683
|
bondingRetryCount =
|
|
5937
6684
|
0 // Reset retry counter on success
|
|
5938
|
-
//
|
|
5939
|
-
//
|
|
5940
|
-
|
|
5941
|
-
//
|
|
5942
|
-
//
|
|
6685
|
+
// Bond != audio profile. Explicitly open A2DP
|
|
6686
|
+
// after createBond() completes (already-bonded
|
|
6687
|
+
// path does this at GATT connect).
|
|
6688
|
+
// markAudioConnected / sendAudioConnected fire
|
|
6689
|
+
// when the A2DP path completes.
|
|
6690
|
+
connectA2dpProfile(device)
|
|
6691
|
+
|
|
6692
|
+
// Safety net: if glasses_ready somehow missed
|
|
6693
|
+
// setting fullyBooted, catch up here.
|
|
5943
6694
|
if (glassesReadyReceived) {
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
6695
|
+
val alreadyBooted =
|
|
6696
|
+
DeviceStore.store.get(
|
|
6697
|
+
"glasses",
|
|
6698
|
+
"fullyBooted"
|
|
6699
|
+
) as? Boolean
|
|
6700
|
+
?: false
|
|
6701
|
+
if (!alreadyBooted) {
|
|
6702
|
+
Bridge.log(
|
|
6703
|
+
"LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
|
|
6704
|
+
)
|
|
6705
|
+
endPairingTiming(
|
|
6706
|
+
"fully_connected",
|
|
6707
|
+
"via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
|
|
6708
|
+
)
|
|
6709
|
+
DeviceStore.apply(
|
|
6710
|
+
"glasses",
|
|
6711
|
+
"fullyBooted",
|
|
6712
|
+
true
|
|
6713
|
+
)
|
|
6714
|
+
updateConnectionState(ConnTypes.CONNECTED)
|
|
6715
|
+
} else {
|
|
6716
|
+
Bridge.log(
|
|
6717
|
+
"LIVE: Audio: CTKD bonded after pairing UI already advanced"
|
|
6718
|
+
)
|
|
6719
|
+
markPairingTiming(
|
|
6720
|
+
"ctkd_bonded_after_fully_connected",
|
|
6721
|
+
"queueSize=${sendQueue.size}"
|
|
6722
|
+
)
|
|
6723
|
+
}
|
|
5953
6724
|
}
|
|
5954
|
-
|
|
5955
|
-
// Send audio connected event for platform parity
|
|
5956
|
-
// with iOS
|
|
5957
|
-
Bridge.sendAudioConnected(device.name)
|
|
5958
6725
|
}
|
|
5959
6726
|
BluetoothDevice.BOND_NONE -> {
|
|
5960
6727
|
Bridge.log(
|
|
5961
6728
|
"LIVE: CTKD: ❌ Bonding failed or removed for device"
|
|
5962
6729
|
)
|
|
6730
|
+
markPairingTiming(
|
|
6731
|
+
"ctkd_bond_none",
|
|
6732
|
+
"prev=$previousBondState queueSize=${sendQueue.size}"
|
|
6733
|
+
)
|
|
5963
6734
|
isBtClassicConnected = false
|
|
5964
6735
|
audioConnected = false
|
|
5965
6736
|
if (previousBondState == BluetoothDevice.BOND_BONDING) {
|
|
@@ -6020,10 +6791,12 @@ class MentraLive : SGCManager() {
|
|
|
6020
6791
|
Bridge.sendAudioDisconnected()
|
|
6021
6792
|
}
|
|
6022
6793
|
}
|
|
6023
|
-
BluetoothDevice.BOND_BONDING ->
|
|
6794
|
+
BluetoothDevice.BOND_BONDING -> {
|
|
6024
6795
|
Bridge.log(
|
|
6025
6796
|
"LIVE: CTKD: 🔄 Bonding in progress with device"
|
|
6026
6797
|
)
|
|
6798
|
+
markPairingTiming("ctkd_bonding")
|
|
6799
|
+
}
|
|
6027
6800
|
else ->
|
|
6028
6801
|
Bridge.log(
|
|
6029
6802
|
"LIVE: CTKD: Unknown bond state: " + bondState
|
|
@@ -6058,6 +6831,48 @@ class MentraLive : SGCManager() {
|
|
|
6058
6831
|
}
|
|
6059
6832
|
}
|
|
6060
6833
|
|
|
6834
|
+
/**
|
|
6835
|
+
* Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
|
|
6836
|
+
* onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
|
|
6837
|
+
* (peer terminate) on reconnect when A2DP is already up.
|
|
6838
|
+
*/
|
|
6839
|
+
private fun maybeInitiateCtkdAudioAfterBleReady() {
|
|
6840
|
+
if (isKilled) {
|
|
6841
|
+
return
|
|
6842
|
+
}
|
|
6843
|
+
val device = connectedDevice
|
|
6844
|
+
if (device == null) {
|
|
6845
|
+
Bridge.log("LIVE: CTKD: Skipping initiate — no connected device")
|
|
6846
|
+
return
|
|
6847
|
+
}
|
|
6848
|
+
if (ctkdInitiatedThisGattSession) {
|
|
6849
|
+
return
|
|
6850
|
+
}
|
|
6851
|
+
ctkdInitiatedThisGattSession = true
|
|
6852
|
+
Bridge.log(
|
|
6853
|
+
"LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
|
|
6854
|
+
device.bondState +
|
|
6855
|
+
")"
|
|
6856
|
+
)
|
|
6857
|
+
markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
|
|
6858
|
+
when (device.bondState) {
|
|
6859
|
+
BluetoothDevice.BOND_BONDED -> {
|
|
6860
|
+
Bridge.log(
|
|
6861
|
+
"LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
|
|
6862
|
+
)
|
|
6863
|
+
markPairingTiming("ctkd_already_bonded")
|
|
6864
|
+
connectA2dpProfile(device)
|
|
6865
|
+
}
|
|
6866
|
+
BluetoothDevice.BOND_BONDING -> {
|
|
6867
|
+
// Secure pairing firmware may request SMP as soon as GATT connects.
|
|
6868
|
+
// Let that in-flight procedure finish instead of calling createBond twice.
|
|
6869
|
+
Bridge.log("LIVE: CTKD: Bonding already in progress - waiting for completion")
|
|
6870
|
+
markPairingTiming("ctkd_bonding_already_in_progress")
|
|
6871
|
+
}
|
|
6872
|
+
else -> createBond(device)
|
|
6873
|
+
}
|
|
6874
|
+
}
|
|
6875
|
+
|
|
6061
6876
|
/**
|
|
6062
6877
|
* Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
|
|
6063
6878
|
* BLE and BT Classic connections automatically
|
|
@@ -6164,6 +6979,7 @@ class MentraLive : SGCManager() {
|
|
|
6164
6979
|
|
|
6165
6980
|
if (state == BluetoothProfile.STATE_CONNECTED) {
|
|
6166
6981
|
Bridge.log("LIVE: A2DP: Already connected to " + device.name)
|
|
6982
|
+
a2dpConnectAttempts = 0
|
|
6167
6983
|
markAudioConnected(device.name)
|
|
6168
6984
|
} else if (state == BluetoothProfile.STATE_DISCONNECTED) {
|
|
6169
6985
|
Bridge.log("LIVE: A2DP: Connecting to " + device.name)
|
|
@@ -6172,35 +6988,48 @@ class MentraLive : SGCManager() {
|
|
|
6172
6988
|
BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
|
|
6173
6989
|
val result = connectMethod.invoke(a2dpProfile, device) as Boolean
|
|
6174
6990
|
Bridge.log("LIVE: A2DP: Connect initiated, result: " + result)
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
markAudioConnected(device.name)
|
|
6991
|
+
if (result) {
|
|
6992
|
+
scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
|
|
6993
|
+
} else {
|
|
6994
|
+
scheduleA2dpConnectRetry(device, "connect() returned false")
|
|
6995
|
+
}
|
|
6181
6996
|
} else if (state == BluetoothProfile.STATE_CONNECTING) {
|
|
6182
|
-
Bridge.log("LIVE: A2DP: Already connecting
|
|
6183
|
-
|
|
6997
|
+
Bridge.log("LIVE: A2DP: Already connecting — waiting for STATE_CONNECTED")
|
|
6998
|
+
scheduleA2dpConnectRetry(device, "still connecting")
|
|
6184
6999
|
} 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
|
-
)
|
|
7000
|
+
scheduleA2dpConnectRetry(device, "device disconnecting")
|
|
6198
7001
|
}
|
|
6199
7002
|
} catch (e: Exception) {
|
|
6200
7003
|
Bridge.log("LIVE: A2DP: Error connecting: " + e.message)
|
|
6201
|
-
|
|
6202
|
-
|
|
7004
|
+
scheduleA2dpConnectRetry(device, "exception: ${e.message}")
|
|
7005
|
+
}
|
|
7006
|
+
}
|
|
7007
|
+
|
|
7008
|
+
private fun scheduleA2dpConnectRetry(device: BluetoothDevice, reason: String) {
|
|
7009
|
+
a2dpConnectAttempts++
|
|
7010
|
+
if (a2dpConnectAttempts > A2DP_CONNECT_MAX_ATTEMPTS) {
|
|
7011
|
+
Bridge.log(
|
|
7012
|
+
"LIVE: A2DP: Giving up after $A2DP_CONNECT_MAX_ATTEMPTS attempts ($reason). " +
|
|
7013
|
+
"Classic bond may exist, but A2DP/HFP is not up. BLE/LC3 still works."
|
|
7014
|
+
)
|
|
7015
|
+
return
|
|
6203
7016
|
}
|
|
7017
|
+
Bridge.log(
|
|
7018
|
+
"LIVE: A2DP: Retry $a2dpConnectAttempts/$A2DP_CONNECT_MAX_ATTEMPTS in " +
|
|
7019
|
+
"${A2DP_CONNECT_RETRY_MS}ms ($reason)"
|
|
7020
|
+
)
|
|
7021
|
+
handler.postDelayed(
|
|
7022
|
+
Runnable {
|
|
7023
|
+
if (isKilled || pairingYieldActive || connectedDevice == null) {
|
|
7024
|
+
return@Runnable
|
|
7025
|
+
}
|
|
7026
|
+
val target = connectedDevice ?: return@Runnable
|
|
7027
|
+
if (a2dpProfile != null) {
|
|
7028
|
+
connectA2dpWithProxy(target)
|
|
7029
|
+
}
|
|
7030
|
+
},
|
|
7031
|
+
A2DP_CONNECT_RETRY_MS
|
|
7032
|
+
)
|
|
6204
7033
|
}
|
|
6205
7034
|
|
|
6206
7035
|
/** Helper to mark audio as connected and notify */
|
|
@@ -6218,8 +7047,14 @@ class MentraLive : SGCManager() {
|
|
|
6218
7047
|
Bridge.log(
|
|
6219
7048
|
"LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
|
|
6220
7049
|
)
|
|
7050
|
+
endPairingTiming(
|
|
7051
|
+
"fully_connected",
|
|
7052
|
+
"via=a2dp+glasses_ready queueSize=${sendQueue.size}"
|
|
7053
|
+
)
|
|
6221
7054
|
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
6222
7055
|
updateConnectionState(ConnTypes.CONNECTED)
|
|
7056
|
+
} else {
|
|
7057
|
+
markPairingTiming("a2dp_connected_waiting_glasses_ready")
|
|
6223
7058
|
}
|
|
6224
7059
|
}
|
|
6225
7060
|
|
|
@@ -6243,6 +7078,7 @@ class MentraLive : SGCManager() {
|
|
|
6243
7078
|
}
|
|
6244
7079
|
|
|
6245
7080
|
Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
|
|
7081
|
+
a2dpConnectAttempts = 0
|
|
6246
7082
|
|
|
6247
7083
|
// If we already have the proxy, try to connect directly
|
|
6248
7084
|
if (a2dpProfile != null) {
|
|
@@ -6263,16 +7099,133 @@ class MentraLive : SGCManager() {
|
|
|
6263
7099
|
Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
|
|
6264
7100
|
} else {
|
|
6265
7101
|
Bridge.log(
|
|
6266
|
-
"LIVE: A2DP: Failed to get profile proxy
|
|
7102
|
+
"LIVE: A2DP: Failed to get profile proxy — Classic audio not connected"
|
|
6267
7103
|
)
|
|
6268
|
-
// Still mark as connected - device is bonded and BLE audio (LC3) will work
|
|
6269
|
-
markAudioConnected(device.name)
|
|
6270
7104
|
}
|
|
6271
7105
|
} else {
|
|
6272
7106
|
Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
|
|
6273
7107
|
}
|
|
6274
7108
|
}
|
|
6275
7109
|
|
|
7110
|
+
/**
|
|
7111
|
+
* Drop Classic audio profiles (A2DP + HFP). App disconnect previously only closed BLE GATT /
|
|
7112
|
+
* the A2DP proxy, so Android Settings still showed Mentra Live as connected and the next BLE
|
|
7113
|
+
* reconnect fought a live Classic link (GATT 19). Bond is intentionally kept for reconnect —
|
|
7114
|
+
* only [forget] calls removeBond.
|
|
7115
|
+
*/
|
|
7116
|
+
private fun disconnectClassicProfiles(device: BluetoothDevice?) {
|
|
7117
|
+
if (device == null || bluetoothAdapter == null || context == null) {
|
|
7118
|
+
Bridge.log("LIVE: Classic: skip profile teardown — missing device/adapter/context")
|
|
7119
|
+
return
|
|
7120
|
+
}
|
|
7121
|
+
Bridge.log(
|
|
7122
|
+
"LIVE: Classic: tearing down A2DP/HFP for " +
|
|
7123
|
+
safeDeviceName(device) +
|
|
7124
|
+
" (" +
|
|
7125
|
+
device.address +
|
|
7126
|
+
")"
|
|
7127
|
+
)
|
|
7128
|
+
val isActiveDevice =
|
|
7129
|
+
connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
|
|
7130
|
+
if (isActiveDevice) {
|
|
7131
|
+
isBtClassicConnected = false
|
|
7132
|
+
audioConnected = false
|
|
7133
|
+
}
|
|
7134
|
+
|
|
7135
|
+
if (a2dpProfile != null) {
|
|
7136
|
+
invokeHiddenProfileDisconnect(
|
|
7137
|
+
a2dpProfile!!,
|
|
7138
|
+
device,
|
|
7139
|
+
BluetoothA2dp::class.java,
|
|
7140
|
+
"A2DP"
|
|
7141
|
+
)
|
|
7142
|
+
} else {
|
|
7143
|
+
requestProfileDisconnect(BluetoothProfile.A2DP, device, BluetoothA2dp::class.java, "A2DP")
|
|
7144
|
+
}
|
|
7145
|
+
// HFP/SCO often stays up after A2DP-only teardown (shows as "Bluetooth Headset" in routes).
|
|
7146
|
+
requestProfileDisconnect(
|
|
7147
|
+
BluetoothProfile.HEADSET,
|
|
7148
|
+
device,
|
|
7149
|
+
BluetoothHeadset::class.java,
|
|
7150
|
+
"HFP"
|
|
7151
|
+
)
|
|
7152
|
+
}
|
|
7153
|
+
|
|
7154
|
+
private fun invokeHiddenProfileDisconnect(
|
|
7155
|
+
proxy: BluetoothProfile,
|
|
7156
|
+
device: BluetoothDevice,
|
|
7157
|
+
proxyClass: Class<*>,
|
|
7158
|
+
label: String
|
|
7159
|
+
) {
|
|
7160
|
+
try {
|
|
7161
|
+
val state = proxy.getConnectionState(device)
|
|
7162
|
+
Bridge.log("LIVE: Classic: " + label + " state=" + state)
|
|
7163
|
+
if (state == BluetoothProfile.STATE_CONNECTED ||
|
|
7164
|
+
state == BluetoothProfile.STATE_CONNECTING
|
|
7165
|
+
) {
|
|
7166
|
+
val disconnectMethod =
|
|
7167
|
+
proxyClass.getMethod("disconnect", BluetoothDevice::class.java)
|
|
7168
|
+
val result = disconnectMethod.invoke(proxy, device) as Boolean
|
|
7169
|
+
Bridge.log("LIVE: Classic: " + label + " disconnect result=" + result)
|
|
7170
|
+
} else {
|
|
7171
|
+
Bridge.log("LIVE: Classic: " + label + " already idle — nothing to disconnect")
|
|
7172
|
+
}
|
|
7173
|
+
} catch (e: Exception) {
|
|
7174
|
+
Bridge.log("LIVE: Classic: " + label + " disconnect error: " + e.message)
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
|
|
7178
|
+
private fun requestProfileDisconnect(
|
|
7179
|
+
profile: Int,
|
|
7180
|
+
device: BluetoothDevice,
|
|
7181
|
+
proxyClass: Class<*>,
|
|
7182
|
+
label: String
|
|
7183
|
+
) {
|
|
7184
|
+
val adapter = bluetoothAdapter ?: return
|
|
7185
|
+
val ctx = context ?: return
|
|
7186
|
+
try {
|
|
7187
|
+
val ok =
|
|
7188
|
+
adapter.getProfileProxy(
|
|
7189
|
+
ctx,
|
|
7190
|
+
object : BluetoothProfile.ServiceListener {
|
|
7191
|
+
override fun onServiceConnected(
|
|
7192
|
+
profileType: Int,
|
|
7193
|
+
proxy: BluetoothProfile
|
|
7194
|
+
) {
|
|
7195
|
+
if (profileType != profile) {
|
|
7196
|
+
return
|
|
7197
|
+
}
|
|
7198
|
+
try {
|
|
7199
|
+
invokeHiddenProfileDisconnect(
|
|
7200
|
+
proxy,
|
|
7201
|
+
device,
|
|
7202
|
+
proxyClass,
|
|
7203
|
+
label
|
|
7204
|
+
)
|
|
7205
|
+
} finally {
|
|
7206
|
+
try {
|
|
7207
|
+
adapter.closeProfileProxy(profile, proxy)
|
|
7208
|
+
} catch (e: Exception) {
|
|
7209
|
+
Bridge.log(
|
|
7210
|
+
"LIVE: Classic: error closing " +
|
|
7211
|
+
label +
|
|
7212
|
+
" proxy: " +
|
|
7213
|
+
e.message
|
|
7214
|
+
)
|
|
7215
|
+
}
|
|
7216
|
+
}
|
|
7217
|
+
}
|
|
7218
|
+
|
|
7219
|
+
override fun onServiceDisconnected(profileType: Int) {}
|
|
7220
|
+
},
|
|
7221
|
+
profile
|
|
7222
|
+
)
|
|
7223
|
+
Bridge.log("LIVE: Classic: requested " + label + " proxy for disconnect, ok=" + ok)
|
|
7224
|
+
} catch (e: Exception) {
|
|
7225
|
+
Bridge.log("LIVE: Classic: failed to request " + label + " proxy: " + e.message)
|
|
7226
|
+
}
|
|
7227
|
+
}
|
|
7228
|
+
|
|
6276
7229
|
/** Close the A2DP profile proxy */
|
|
6277
7230
|
private fun closeA2dpProxy() {
|
|
6278
7231
|
if (a2dpProfile != null && bluetoothAdapter != null) {
|
|
@@ -6288,6 +7241,14 @@ class MentraLive : SGCManager() {
|
|
|
6288
7241
|
|
|
6289
7242
|
// Mark as killed to prevent reconnection attempts
|
|
6290
7243
|
isKilled = true
|
|
7244
|
+
explicitConnectByName = false
|
|
7245
|
+
pairingYieldActive = false
|
|
7246
|
+
pairingYieldAwaitingReclaim = false
|
|
7247
|
+
pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
|
|
7248
|
+
pairingYieldEndRunnable = null
|
|
7249
|
+
unpairFlushRunnable?.let { handler.removeCallbacks(it) }
|
|
7250
|
+
unpairFlushRunnable = null
|
|
7251
|
+
unpairFlushPending = false
|
|
6291
7252
|
|
|
6292
7253
|
// Stop scanning if in progress
|
|
6293
7254
|
if (isScanning) {
|
|
@@ -6298,7 +7259,10 @@ class MentraLive : SGCManager() {
|
|
|
6298
7259
|
// CTKD Implementation: Unregister bonding receiver
|
|
6299
7260
|
unregisterBondingReceiver()
|
|
6300
7261
|
|
|
6301
|
-
//
|
|
7262
|
+
// Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
|
|
7263
|
+
// phone's system Bluetooth UI showing Mentra Live still connected.
|
|
7264
|
+
val classicDevice = connectedDevice
|
|
7265
|
+
disconnectClassicProfiles(classicDevice)
|
|
6302
7266
|
closeA2dpProxy()
|
|
6303
7267
|
|
|
6304
7268
|
// Stop readiness check loop
|
|
@@ -6369,6 +7333,9 @@ class MentraLive : SGCManager() {
|
|
|
6369
7333
|
reconnectAttempts = 0
|
|
6370
7334
|
isReconnecting = false
|
|
6371
7335
|
glassesReady = false
|
|
7336
|
+
glassesReadyReceived = false
|
|
7337
|
+
audioConnected = false
|
|
7338
|
+
ctkdInitiatedThisGattSession = false
|
|
6372
7339
|
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
6373
7340
|
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
6374
7341
|
|
|
@@ -7388,6 +8355,7 @@ class MentraLive : SGCManager() {
|
|
|
7388
8355
|
/** Sends phone_ready to (re)start the glasses-driven readiness flow. */
|
|
7389
8356
|
private fun sendPhoneReady(reason: String) {
|
|
7390
8357
|
Bridge.log("LIVE: 📱 Sending phone_ready to glasses ($reason) - waiting for glasses_ready response")
|
|
8358
|
+
markPairingTiming("phone_ready_sent", "reason=$reason queueSize=${sendQueue.size}")
|
|
7391
8359
|
val readyMsg = JSONObject()
|
|
7392
8360
|
readyMsg.put("type", "phone_ready")
|
|
7393
8361
|
readyMsg.put("timestamp", System.currentTimeMillis())
|
|
@@ -7511,6 +8479,10 @@ class MentraLive : SGCManager() {
|
|
|
7511
8479
|
payload
|
|
7512
8480
|
)
|
|
7513
8481
|
Bridge.log("LIVE: Sending BLE wire v2 handshake")
|
|
8482
|
+
markPairingTiming(
|
|
8483
|
+
"wire_handshake_sent",
|
|
8484
|
+
"attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
|
|
8485
|
+
)
|
|
7514
8486
|
wireHandshakeQueued = true
|
|
7515
8487
|
wireHandshakeSentGeneration = wireSessionGeneration
|
|
7516
8488
|
queueData(packed, null)
|
|
@@ -7537,6 +8509,10 @@ class MentraLive : SGCManager() {
|
|
|
7537
8509
|
WIRE_HANDSHAKE_MAX_ATTEMPTS +
|
|
7538
8510
|
") - re-arming"
|
|
7539
8511
|
)
|
|
8512
|
+
markPairingTiming(
|
|
8513
|
+
"wire_handshake_unanswered",
|
|
8514
|
+
"attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
|
|
8515
|
+
)
|
|
7540
8516
|
wireHandshakeQueued = false
|
|
7541
8517
|
if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
|
|
7542
8518
|
maybeSendWireHandshake()
|
|
@@ -7565,6 +8541,7 @@ class MentraLive : SGCManager() {
|
|
|
7565
8541
|
Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
|
|
7566
8542
|
return
|
|
7567
8543
|
}
|
|
8544
|
+
markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
|
|
7568
8545
|
activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
|
|
7569
8546
|
}
|
|
7570
8547
|
|
|
@@ -8238,6 +9215,19 @@ class MentraLive : SGCManager() {
|
|
|
8238
9215
|
}
|
|
8239
9216
|
}
|
|
8240
9217
|
|
|
9218
|
+
override fun sendWifiAdbState(enabled: Boolean) {
|
|
9219
|
+
Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state to glasses - enabled: " + enabled)
|
|
9220
|
+
try {
|
|
9221
|
+
val command = JSONObject()
|
|
9222
|
+
command.put("type", "set_wifi_adb_state")
|
|
9223
|
+
command.put("enabled", enabled)
|
|
9224
|
+
sendJson(command, true)
|
|
9225
|
+
Bridge.log("LIVE: 🔧 ✅ Wi-Fi ADB state command sent successfully")
|
|
9226
|
+
} catch (e: JSONException) {
|
|
9227
|
+
Log.e(TAG, "🔧 💥 Error creating Wi-Fi ADB state JSON", e)
|
|
9228
|
+
}
|
|
9229
|
+
}
|
|
9230
|
+
|
|
8241
9231
|
override fun sendSetSystemTime(timestampMs: Long) {
|
|
8242
9232
|
Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
|
|
8243
9233
|
try {
|
|
@@ -9425,6 +10415,21 @@ class MentraLive : SGCManager() {
|
|
|
9425
10415
|
stopVideoRecording(requestId, null, null)
|
|
9426
10416
|
}
|
|
9427
10417
|
|
|
10418
|
+
override fun queryVideoRecordingStatus(requestId: String) {
|
|
10419
|
+
if (!isConnected) {
|
|
10420
|
+
Log.w(TAG, "Cannot query video recording status - not connected")
|
|
10421
|
+
return
|
|
10422
|
+
}
|
|
10423
|
+
try {
|
|
10424
|
+
val json = JSONObject()
|
|
10425
|
+
json.put("type", "get_video_recording_status")
|
|
10426
|
+
json.put("requestId", requestId)
|
|
10427
|
+
sendJson(json, true)
|
|
10428
|
+
} catch (e: JSONException) {
|
|
10429
|
+
Log.e(TAG, "Failed to create video recording status query", e)
|
|
10430
|
+
}
|
|
10431
|
+
}
|
|
10432
|
+
|
|
9428
10433
|
override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
9429
10434
|
Bridge.log(
|
|
9430
10435
|
"LIVE: Stopping video recording: requestId=" +
|
|
@@ -9475,20 +10480,6 @@ class MentraLive : SGCManager() {
|
|
|
9475
10480
|
val sequenceNumber = data[1]
|
|
9476
10481
|
val receiveTime = System.currentTimeMillis()
|
|
9477
10482
|
|
|
9478
|
-
// Basic sequence validation
|
|
9479
|
-
if (lastReceivedLc3Sequence != (-1).toByte() &&
|
|
9480
|
-
(lastReceivedLc3Sequence + 1).toByte() != sequenceNumber
|
|
9481
|
-
) {
|
|
9482
|
-
Log.w(
|
|
9483
|
-
TAG,
|
|
9484
|
-
"LC3 packet sequence mismatch. Expected: " +
|
|
9485
|
-
(lastReceivedLc3Sequence + 1) +
|
|
9486
|
-
", Got: " +
|
|
9487
|
-
sequenceNumber
|
|
9488
|
-
)
|
|
9489
|
-
}
|
|
9490
|
-
lastReceivedLc3Sequence = sequenceNumber
|
|
9491
|
-
|
|
9492
10483
|
val lc3Data = Arrays.copyOfRange(data, 2, data.size)
|
|
9493
10484
|
|
|
9494
10485
|
// Enhanced LC3 packet logging and saving
|
|
@@ -9500,7 +10491,11 @@ class MentraLive : SGCManager() {
|
|
|
9500
10491
|
|
|
9501
10492
|
// Forward raw LC3 to DeviceManager (matches iOS behavior)
|
|
9502
10493
|
// MentraLive uses 40-byte LC3 frames
|
|
9503
|
-
DeviceManager.getInstance().handleGlassesMicData(
|
|
10494
|
+
DeviceManager.getInstance().handleGlassesMicData(
|
|
10495
|
+
lc3Data,
|
|
10496
|
+
LC3_FRAME_SIZE,
|
|
10497
|
+
sequenceNumber.toInt() and 0xff,
|
|
10498
|
+
)
|
|
9504
10499
|
|
|
9505
10500
|
// Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
|
|
9506
10501
|
// } else {
|