@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +216 -23
- 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/BesOtaHeartbeatGuard.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1393 -284
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/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/BesOtaHeartbeatGuardTest.kt +73 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +84 -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 +154 -19
- 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 +512 -78
- 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 +97 -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
|
|
@@ -27,6 +28,7 @@ import android.os.Build
|
|
|
27
28
|
import android.os.Handler
|
|
28
29
|
import android.os.HandlerThread
|
|
29
30
|
import android.os.Looper
|
|
31
|
+
import android.os.SystemClock
|
|
30
32
|
import android.util.Log
|
|
31
33
|
import androidx.core.app.ActivityCompat
|
|
32
34
|
import com.mentra.bluetoothsdk.BluetoothSdkDefaults
|
|
@@ -75,6 +77,38 @@ import org.json.JSONArray
|
|
|
75
77
|
import org.json.JSONException
|
|
76
78
|
import org.json.JSONObject
|
|
77
79
|
|
|
80
|
+
internal fun hasRequiredMentraLiveCoreCharacteristics(
|
|
81
|
+
hasRxCharacteristic: Boolean,
|
|
82
|
+
hasTxCharacteristic: Boolean,
|
|
83
|
+
): Boolean = hasRxCharacteristic && hasTxCharacteristic
|
|
84
|
+
|
|
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
|
+
|
|
78
112
|
/**
|
|
79
113
|
* Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
|
|
80
114
|
* glasses
|
|
@@ -82,7 +116,14 @@ import org.json.JSONObject
|
|
|
82
116
|
* Note: Mentra Live glasses have no display capabilities, only camera and microphone. All
|
|
83
117
|
* display-related methods are stubbed out and will log a message but not actually display anything.
|
|
84
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
|
+
|
|
85
125
|
class MentraLive : SGCManager() {
|
|
126
|
+
private val besOtaHeartbeatGuard = BesOtaHeartbeatGuard()
|
|
86
127
|
|
|
87
128
|
companion object {
|
|
88
129
|
private const val TAG = "Live"
|
|
@@ -97,6 +138,9 @@ class MentraLive : SGCManager() {
|
|
|
97
138
|
private const val VOICE_ACTIVITY_DETECTION_SWITCH_TYPE = 8
|
|
98
139
|
private const val LOUDNESS_GATE_SWITCH_TYPE = 10
|
|
99
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
|
|
100
144
|
|
|
101
145
|
// L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
|
|
102
146
|
// PSM. When the phone opens the channel, the glasses send file packets over it instead
|
|
@@ -140,6 +184,253 @@ class MentraLive : SGCManager() {
|
|
|
140
184
|
10000 // 10 seconds for reconnection scans (faster than 60s default)
|
|
141
185
|
private const val POST_SHUTDOWN_RECONNECT_DELAY_MS =
|
|
142
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
|
+
|
|
143
434
|
private const val SHUTDOWN_RECENT_MS = 45_000L // Consider "recent shutdown" for 45s
|
|
144
435
|
|
|
145
436
|
// Keep-alive parameters
|
|
@@ -251,6 +542,16 @@ class MentraLive : SGCManager() {
|
|
|
251
542
|
|
|
252
543
|
private var reconnectAttempts = 0
|
|
253
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
|
|
254
555
|
/**
|
|
255
556
|
* Timestamp when sr_shut (K900 shutdown) was last received; used to delay first reconnect scan
|
|
256
557
|
* so glasses can reboot.
|
|
@@ -274,18 +575,35 @@ class MentraLive : SGCManager() {
|
|
|
274
575
|
private val fileProcessingHandler = Handler(fileProcessingThread.looper)
|
|
275
576
|
private var scheduler: ScheduledExecutorService? = null
|
|
276
577
|
private var isScanning = false
|
|
578
|
+
// Prevent an old scan timeout from stopping a newer manual scan.
|
|
579
|
+
private var scanGeneration = 0
|
|
277
580
|
private var isConnecting = false
|
|
278
581
|
private var isKilled = false
|
|
279
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
|
+
|
|
280
592
|
// CTKD (Cross-Transport Key Derivation) support for BES devices
|
|
281
593
|
private var isBondingReceiverRegistered = false
|
|
282
594
|
private var isBtClassicConnected = false
|
|
283
595
|
private var bondingReceiver: BroadcastReceiver? = null
|
|
284
596
|
private var bondingRetryCount = 0
|
|
285
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
|
+
|
|
286
603
|
// A2DP profile connection for already-bonded devices
|
|
287
604
|
private var a2dpProfile: BluetoothA2dp? = null
|
|
288
605
|
private var isA2dpProxyRegistered = false
|
|
606
|
+
private var a2dpConnectAttempts = 0
|
|
289
607
|
|
|
290
608
|
private data class OutgoingBleCommandTraceInfo(
|
|
291
609
|
val commandType: String,
|
|
@@ -310,7 +628,11 @@ class MentraLive : SGCManager() {
|
|
|
310
628
|
val queuedAtMs: Long
|
|
311
629
|
)
|
|
312
630
|
|
|
313
|
-
private data class QueuedBleWrite(
|
|
631
|
+
private data class QueuedBleWrite(
|
|
632
|
+
val data: ByteArray,
|
|
633
|
+
val trace: BleWriteTrace?,
|
|
634
|
+
val sessionGeneration: Long
|
|
635
|
+
)
|
|
314
636
|
|
|
315
637
|
private var sendQueue = ConcurrentLinkedQueue<QueuedBleWrite>()
|
|
316
638
|
private val bleWriteTraceSequence = AtomicLong(1)
|
|
@@ -603,6 +925,9 @@ class MentraLive : SGCManager() {
|
|
|
603
925
|
// Audio Pairing: Track readiness separately for BLE and audio (matches iOS implementation)
|
|
604
926
|
private var glassesReadyReceived = false
|
|
605
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
|
|
606
931
|
|
|
607
932
|
// Micbeat tracking - periodically enable custom audio TX
|
|
608
933
|
private var micBeatHandler = Handler(Looper.getMainLooper())
|
|
@@ -612,12 +937,12 @@ class MentraLive : SGCManager() {
|
|
|
612
937
|
// Message tracking for reliable delivery
|
|
613
938
|
private val pendingMessages = ConcurrentHashMap<Long, PendingMessage>()
|
|
614
939
|
private val messageIdCounter = AtomicLong(1)
|
|
940
|
+
private val bleSessionGeneration = AtomicLong(0)
|
|
615
941
|
|
|
616
942
|
// Esoteric message ID generation
|
|
617
943
|
private val secureRandom = SecureRandom()
|
|
618
944
|
private val deviceId = System.currentTimeMillis() xor Random().nextLong()
|
|
619
945
|
|
|
620
|
-
private var lastReceivedLc3Sequence: Byte = -1
|
|
621
946
|
private var lc3SequenceNumber: Byte = 0
|
|
622
947
|
private var lc3DecoderPtr = 0L
|
|
623
948
|
private var lc3AudioPlayer: Lc3Player? = null
|
|
@@ -637,7 +962,8 @@ class MentraLive : SGCManager() {
|
|
|
637
962
|
val messageData: String,
|
|
638
963
|
val timestamp: Long,
|
|
639
964
|
val retryCount: Int,
|
|
640
|
-
val retryRunnable: Runnable
|
|
965
|
+
val retryRunnable: Runnable,
|
|
966
|
+
val sessionGeneration: Long
|
|
641
967
|
)
|
|
642
968
|
|
|
643
969
|
private var lc3AudioFileStream: FileOutputStream? = null
|
|
@@ -803,10 +1129,14 @@ class MentraLive : SGCManager() {
|
|
|
803
1129
|
}
|
|
804
1130
|
|
|
805
1131
|
private fun updateConnectionState(state: String) {
|
|
1132
|
+
if (state == ConnTypes.DISCONNECTED) {
|
|
1133
|
+
DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
|
|
1134
|
+
}
|
|
806
1135
|
val isEqual = state == connectionState
|
|
807
1136
|
if (isEqual) {
|
|
808
1137
|
if (state == ConnTypes.DISCONNECTED) {
|
|
809
1138
|
resetWireNegotiationState()
|
|
1139
|
+
resetPendingAckState()
|
|
810
1140
|
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
811
1141
|
// (e.g. a stale handshake whose reply activates v2 before the new session
|
|
812
1142
|
// negotiated) is the bug class this clear removes. Higher layers re-send
|
|
@@ -824,6 +1154,7 @@ class MentraLive : SGCManager() {
|
|
|
824
1154
|
// same-link glasses_ready (e.g. ASG restart) re-publishes CONNECTED.
|
|
825
1155
|
DeviceStore.apply("glasses", "serialNumber", "")
|
|
826
1156
|
DeviceStore.apply("glasses", "bluetoothMacAddress", "")
|
|
1157
|
+
DeviceStore.apply("glasses", "wifiMacAddress", "")
|
|
827
1158
|
}
|
|
828
1159
|
|
|
829
1160
|
// Actually update the connection state!
|
|
@@ -854,6 +1185,7 @@ class MentraLive : SGCManager() {
|
|
|
854
1185
|
DeviceStore.apply("glasses", "signalStrength", -1)
|
|
855
1186
|
DeviceStore.apply("glasses", "signalStrengthUpdatedAt", 0L)
|
|
856
1187
|
resetWireNegotiationState()
|
|
1188
|
+
resetPendingAckState()
|
|
857
1189
|
sendQueue.clear() // see the disconnect reset above: stale writes die with the session
|
|
858
1190
|
|
|
859
1191
|
// Drop OTA caches when fully disconnected — avoids leaking session/step state
|
|
@@ -874,6 +1206,17 @@ class MentraLive : SGCManager() {
|
|
|
874
1206
|
cachedOtaCurrentStep = 0
|
|
875
1207
|
cachedOtaStepSequence = null
|
|
876
1208
|
lastBesOtaProgress = -1
|
|
1209
|
+
// The bounded UART-ownership lease is transport state, not session-cache state. Keep it
|
|
1210
|
+
// through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private fun updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
|
|
1214
|
+
besOtaHeartbeatGuard.observeOtaStatus(
|
|
1215
|
+
stepType,
|
|
1216
|
+
phase,
|
|
1217
|
+
status,
|
|
1218
|
+
SystemClock.elapsedRealtime(),
|
|
1219
|
+
)
|
|
877
1220
|
}
|
|
878
1221
|
|
|
879
1222
|
protected fun setFontSizes() {
|
|
@@ -884,6 +1227,10 @@ class MentraLive : SGCManager() {
|
|
|
884
1227
|
|
|
885
1228
|
/** Starts BLE scanning for Mentra Live glasses */
|
|
886
1229
|
private fun startScan() {
|
|
1230
|
+
if (pairingYieldActive) {
|
|
1231
|
+
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
887
1234
|
if (bluetoothAdapter == null || isScanning) {
|
|
888
1235
|
return
|
|
889
1236
|
}
|
|
@@ -942,6 +1289,7 @@ class MentraLive : SGCManager() {
|
|
|
942
1289
|
)
|
|
943
1290
|
}
|
|
944
1291
|
|
|
1292
|
+
val generation = ++scanGeneration
|
|
945
1293
|
isScanning = true
|
|
946
1294
|
bluetoothScanner!!.startScan(filters, settings, scanCallback)
|
|
947
1295
|
|
|
@@ -949,6 +1297,9 @@ class MentraLive : SGCManager() {
|
|
|
949
1297
|
handler.postDelayed(
|
|
950
1298
|
object : Runnable {
|
|
951
1299
|
override fun run() {
|
|
1300
|
+
if (generation != scanGeneration) {
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
952
1303
|
if (isScanning) {
|
|
953
1304
|
stopScan()
|
|
954
1305
|
emitStopScanEvent()
|
|
@@ -992,11 +1343,13 @@ class MentraLive : SGCManager() {
|
|
|
992
1343
|
|
|
993
1344
|
/** Stops BLE scanning */
|
|
994
1345
|
override fun stopScan() {
|
|
1346
|
+
manualDiscoveryActive = false
|
|
995
1347
|
if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
|
|
996
1348
|
return
|
|
997
1349
|
}
|
|
998
1350
|
|
|
999
1351
|
try {
|
|
1352
|
+
scanGeneration++
|
|
1000
1353
|
bluetoothScanner!!.stopScan(scanCallback)
|
|
1001
1354
|
isScanning = false
|
|
1002
1355
|
DeviceStore.apply("bluetooth", "searching", false)
|
|
@@ -1020,6 +1373,48 @@ class MentraLive : SGCManager() {
|
|
|
1020
1373
|
Bridge.sendTypedMessage("compatible_glasses_search_stop", body)
|
|
1021
1374
|
}
|
|
1022
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
|
+
|
|
1023
1418
|
var seenDevices: MutableSet<String> = HashSet()
|
|
1024
1419
|
|
|
1025
1420
|
/** BLE Scan callback */
|
|
@@ -1072,42 +1467,78 @@ class MentraLive : SGCManager() {
|
|
|
1072
1467
|
deviceName.startsWith("MENTRA_LIVE_BT") ||
|
|
1073
1468
|
deviceName.lowercase().startsWith("mentra_live")
|
|
1074
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
|
+
}
|
|
1075
1498
|
val glassType = if (deviceName == "Xy_A") "Standard" else "K900"
|
|
1499
|
+
val (pairingMode, pairingCode, secureCapable) = parseSecurePairingTrailer(result)
|
|
1076
1500
|
Bridge.log(
|
|
1077
1501
|
"LIVE: Found compatible " +
|
|
1078
1502
|
glassType +
|
|
1079
1503
|
" glasses device: " +
|
|
1080
|
-
deviceName
|
|
1504
|
+
deviceName +
|
|
1505
|
+
if (secureCapable) " (pairingMode=$pairingMode)"
|
|
1506
|
+
else " (legacy firmware, pairable)"
|
|
1081
1507
|
)
|
|
1082
|
-
// EventBus.getDefault().post(new GlassesBluetoothSearchDiscoverEvent(
|
|
1083
|
-
// smartGlassesDevice.deviceModelName, deviceName));
|
|
1084
1508
|
Bridge.sendDiscoveredDevice(
|
|
1085
1509
|
DeviceTypes.LIVE,
|
|
1086
1510
|
deviceName,
|
|
1087
1511
|
deviceAddress,
|
|
1088
|
-
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,
|
|
1089
1518
|
)
|
|
1090
1519
|
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
if (
|
|
1520
|
+
// connectById / reconnect: user (or reconnect) asked for this name —
|
|
1521
|
+
// GATT-connect. Discovery-only scans never set explicitConnectByName.
|
|
1522
|
+
if (isNamedConnectTarget) {
|
|
1094
1523
|
Log.i(
|
|
1095
1524
|
TAG,
|
|
1096
|
-
"🔌 🎯
|
|
1525
|
+
"🔌 🎯 NAMED CONNECT TARGET FOUND - Device: " +
|
|
1097
1526
|
deviceName +
|
|
1098
1527
|
" (Attempt #" +
|
|
1099
1528
|
reconnectAttempts +
|
|
1100
1529
|
")"
|
|
1101
1530
|
)
|
|
1102
1531
|
Bridge.log(
|
|
1103
|
-
"LIVE: 🔌 🎯 Found
|
|
1532
|
+
"LIVE: 🔌 🎯 Found target device by name, connecting: " +
|
|
1104
1533
|
deviceName +
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1534
|
+
(if (isReconnecting)
|
|
1535
|
+
" (Reconnect attempt #" +
|
|
1536
|
+
reconnectAttempts +
|
|
1537
|
+
")"
|
|
1538
|
+
else " (connectById)")
|
|
1108
1539
|
)
|
|
1109
1540
|
synchronized(connectionLock) {
|
|
1110
|
-
if (isConnected || isConnecting) {
|
|
1541
|
+
if (pairingYieldActive || isConnected || isConnecting) {
|
|
1111
1542
|
return
|
|
1112
1543
|
}
|
|
1113
1544
|
isConnecting = true
|
|
@@ -1115,6 +1546,7 @@ class MentraLive : SGCManager() {
|
|
|
1115
1546
|
stopScan()
|
|
1116
1547
|
emitStopScanEvent()
|
|
1117
1548
|
isReconnecting = false
|
|
1549
|
+
explicitConnectByName = false
|
|
1118
1550
|
connectToDevice(device)
|
|
1119
1551
|
}
|
|
1120
1552
|
}
|
|
@@ -1176,11 +1608,67 @@ class MentraLive : SGCManager() {
|
|
|
1176
1608
|
}
|
|
1177
1609
|
}
|
|
1178
1610
|
|
|
1611
|
+
/**
|
|
1612
|
+
* Reject callbacks delivered by a GATT object from an earlier connection attempt. Without
|
|
1613
|
+
* this identity check, a late disconnect can tear down the active connection and clear its
|
|
1614
|
+
* session-bound send state.
|
|
1615
|
+
*/
|
|
1616
|
+
private fun isCurrentGattCallback(gatt: BluetoothGatt): Boolean {
|
|
1617
|
+
if (gatt === bluetoothGatt) {
|
|
1618
|
+
return true
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
Log.w(TAG, "🔌 Ignoring callback from stale BluetoothGatt")
|
|
1622
|
+
Bridge.log("LIVE: 🔌 Ignoring callback from stale BluetoothGatt session")
|
|
1623
|
+
try {
|
|
1624
|
+
gatt.close()
|
|
1625
|
+
} catch (e: Exception) {
|
|
1626
|
+
Log.w(TAG, "🔌 Failed to close stale BluetoothGatt: " + e)
|
|
1627
|
+
}
|
|
1628
|
+
return false
|
|
1629
|
+
}
|
|
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
|
+
|
|
1179
1657
|
/** Connect to a specific BLE device */
|
|
1180
1658
|
private fun connectToDevice(device: BluetoothDevice?) {
|
|
1181
1659
|
if (device == null) {
|
|
1182
1660
|
return
|
|
1183
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
|
+
)
|
|
1184
1672
|
|
|
1185
1673
|
// Cancel any previous connection timeouts
|
|
1186
1674
|
if (connectionTimeoutRunnable != null) {
|
|
@@ -1280,6 +1768,85 @@ class MentraLive : SGCManager() {
|
|
|
1280
1768
|
// }
|
|
1281
1769
|
// }
|
|
1282
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
|
+
|
|
1283
1850
|
/** Handle reconnection with exponential backoff */
|
|
1284
1851
|
private fun handleReconnection() {
|
|
1285
1852
|
// Don't attempt reconnection if we've been killed/forgotten
|
|
@@ -1289,6 +1856,20 @@ class MentraLive : SGCManager() {
|
|
|
1289
1856
|
return
|
|
1290
1857
|
}
|
|
1291
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
|
+
|
|
1292
1873
|
if (isReconnecting) {
|
|
1293
1874
|
Bridge.log("LIVE: 🔌 RECONNECT ABORTED - already reconnecting")
|
|
1294
1875
|
return
|
|
@@ -1360,10 +1941,25 @@ class MentraLive : SGCManager() {
|
|
|
1360
1941
|
"ms)"
|
|
1361
1942
|
)
|
|
1362
1943
|
|
|
1363
|
-
// 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
|
|
1364
1947
|
handler.postDelayed(
|
|
1365
1948
|
object : Runnable {
|
|
1366
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
|
+
}
|
|
1367
1963
|
if (!isConnected && !isConnecting && !isKilled) {
|
|
1368
1964
|
// Prefer saved MAC for direct GATT connect (faster and more reliable
|
|
1369
1965
|
// than scanning).
|
|
@@ -1498,6 +2094,10 @@ class MentraLive : SGCManager() {
|
|
|
1498
2094
|
status: Int,
|
|
1499
2095
|
newState: Int
|
|
1500
2096
|
) {
|
|
2097
|
+
if (!isCurrentGattCallback(gatt)) {
|
|
2098
|
+
return
|
|
2099
|
+
}
|
|
2100
|
+
|
|
1501
2101
|
// Cancel the connection timeout
|
|
1502
2102
|
if (connectionTimeoutRunnable != null) {
|
|
1503
2103
|
connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
|
|
@@ -1509,9 +2109,11 @@ class MentraLive : SGCManager() {
|
|
|
1509
2109
|
Bridge.log(
|
|
1510
2110
|
"LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
|
|
1511
2111
|
)
|
|
2112
|
+
markPairingTiming("gatt_connected")
|
|
1512
2113
|
isConnecting = false
|
|
1513
2114
|
isConnected = true
|
|
1514
2115
|
connectedDevice = gatt.device
|
|
2116
|
+
emitConnectedPendingDeviceForPairingScan()
|
|
1515
2117
|
|
|
1516
2118
|
DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
|
|
1517
2119
|
// Persist MAC so reconnection can use direct GATT instead of scanning
|
|
@@ -1536,42 +2138,27 @@ class MentraLive : SGCManager() {
|
|
|
1536
2138
|
// connectedDevice.getName());
|
|
1537
2139
|
// }
|
|
1538
2140
|
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
2141
|
+
// Register early so we don't miss bond broadcasts, but defer
|
|
2142
|
+
// createBond / A2DP until BLE GATT setup finishes (see
|
|
2143
|
+
// maybeInitiateCtkdAudioAfterBleReady).
|
|
1541
2144
|
registerBondingReceiver()
|
|
1542
|
-
bondingRetryCount = 0
|
|
2145
|
+
bondingRetryCount = 0
|
|
2146
|
+
ctkdInitiatedThisGattSession = false
|
|
1543
2147
|
Bridge.log(
|
|
1544
|
-
"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}"
|
|
1545
2153
|
)
|
|
1546
|
-
|
|
1547
|
-
// Check if device is already bonded before attempting to create bond
|
|
1548
|
-
if (connectedDevice!!.bondState == BluetoothDevice.BOND_BONDED) {
|
|
1549
|
-
Bridge.log(
|
|
1550
|
-
"LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
|
|
1551
|
-
)
|
|
1552
|
-
// Device is bonded but we need to explicitly connect the A2DP audio
|
|
1553
|
-
// profile
|
|
1554
|
-
// Just being bonded doesn't mean the audio profile is connected
|
|
1555
|
-
connectA2dpProfile(connectedDevice!!)
|
|
1556
|
-
// Note: audioConnected will be set to true once A2DP profile
|
|
1557
|
-
// connects
|
|
1558
|
-
} else {
|
|
1559
|
-
createBond(connectedDevice!!)
|
|
1560
|
-
}
|
|
1561
2154
|
|
|
1562
2155
|
// Discover services
|
|
1563
2156
|
gatt.discoverServices()
|
|
1564
2157
|
|
|
1565
|
-
//
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
isReconnecting = false
|
|
1569
|
-
Log.i(
|
|
1570
|
-
TAG,
|
|
1571
|
-
"🔌 ✅ Reconnection counter reset (was at " +
|
|
1572
|
-
previousAttempts +
|
|
1573
|
-
" attempts)"
|
|
1574
|
-
)
|
|
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
|
|
1575
2162
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
1576
2163
|
Log.w(
|
|
1577
2164
|
TAG,
|
|
@@ -1580,6 +2167,10 @@ class MentraLive : SGCManager() {
|
|
|
1580
2167
|
Bridge.log(
|
|
1581
2168
|
"LIVE: 🔌 ⚠️ Disconnected from GATT server - Will attempt reconnection"
|
|
1582
2169
|
)
|
|
2170
|
+
endPairingTiming(
|
|
2171
|
+
"gatt_disconnected",
|
|
2172
|
+
"queueSize=${sendQueue.size}"
|
|
2173
|
+
)
|
|
1583
2174
|
isConnected = false
|
|
1584
2175
|
isConnecting = false
|
|
1585
2176
|
|
|
@@ -1590,6 +2181,7 @@ class MentraLive : SGCManager() {
|
|
|
1590
2181
|
|
|
1591
2182
|
// Reset audio pairing flags
|
|
1592
2183
|
glassesReadyReceived = false
|
|
2184
|
+
ctkdInitiatedThisGattSession = false
|
|
1593
2185
|
audioConnected = false
|
|
1594
2186
|
|
|
1595
2187
|
notificationsEnabled = false
|
|
@@ -1648,6 +2240,10 @@ class MentraLive : SGCManager() {
|
|
|
1648
2240
|
status +
|
|
1649
2241
|
") - Will retry reconnection"
|
|
1650
2242
|
)
|
|
2243
|
+
endPairingTiming(
|
|
2244
|
+
"gatt_error",
|
|
2245
|
+
"status=$status queueSize=${sendQueue.size}"
|
|
2246
|
+
)
|
|
1651
2247
|
isConnected = false
|
|
1652
2248
|
isConnecting = false
|
|
1653
2249
|
glassesReady = false
|
|
@@ -1655,6 +2251,7 @@ class MentraLive : SGCManager() {
|
|
|
1655
2251
|
readinessCompletedThisBleSession = false
|
|
1656
2252
|
glassesReadyReceived = false
|
|
1657
2253
|
audioConnected = false
|
|
2254
|
+
ctkdInitiatedThisGattSession = false
|
|
1658
2255
|
|
|
1659
2256
|
notificationsEnabled = false
|
|
1660
2257
|
|
|
@@ -1678,6 +2275,19 @@ class MentraLive : SGCManager() {
|
|
|
1678
2275
|
// Clean up resources
|
|
1679
2276
|
closeGattQuietly(false)
|
|
1680
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
|
+
|
|
1681
2291
|
// Attempt reconnection if not killed
|
|
1682
2292
|
if (!isKilled) {
|
|
1683
2293
|
Log.i(TAG, "🔌 🔄 Retrying after GATT error...")
|
|
@@ -1728,24 +2338,47 @@ class MentraLive : SGCManager() {
|
|
|
1728
2338
|
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
|
|
1729
2339
|
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
|
|
1730
2340
|
|
|
1731
|
-
//
|
|
2341
|
+
// LC3 audio is optional. Older firmware can still run the core session
|
|
2342
|
+
// and receive an OTA without these characteristics.
|
|
1732
2343
|
lc3ReadCharacteristic = service.getCharacteristic(LC3_READ_UUID)
|
|
1733
2344
|
lc3WriteCharacteristic = service.getCharacteristic(LC3_WRITE_UUID)
|
|
1734
2345
|
|
|
1735
|
-
//
|
|
2346
|
+
// Match iOS: only the core transport pair is required for readiness.
|
|
1736
2347
|
val hasRequiredCharacteristics =
|
|
1737
|
-
(
|
|
1738
|
-
|
|
1739
|
-
|
|
2348
|
+
hasRequiredMentraLiveCoreCharacteristics(
|
|
2349
|
+
hasRxCharacteristic = rxCharacteristic != null,
|
|
2350
|
+
hasTxCharacteristic = txCharacteristic != null,
|
|
2351
|
+
)
|
|
2352
|
+
val hasLc3AudioCharacteristics =
|
|
2353
|
+
lc3ReadCharacteristic != null && lc3WriteCharacteristic != null
|
|
1740
2354
|
|
|
1741
2355
|
if (hasRequiredCharacteristics) {
|
|
2356
|
+
if (!hasLc3AudioCharacteristics) {
|
|
2357
|
+
Bridge.log(
|
|
2358
|
+
"LIVE: ⚠️ LC3 audio characteristics unavailable - continuing with core BLE transport"
|
|
2359
|
+
)
|
|
2360
|
+
}
|
|
1742
2361
|
// BLE connection established, but we still need to wait for glasses
|
|
1743
2362
|
// SOC
|
|
1744
2363
|
Bridge.log(
|
|
1745
|
-
"LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX
|
|
2364
|
+
"LIVE: 🔌 ✅ BLE reconnection fully ready (Core TX/RX verified, LC3 audio available: " +
|
|
2365
|
+
hasLc3AudioCharacteristics +
|
|
2366
|
+
")"
|
|
1746
2367
|
)
|
|
2368
|
+
markPairingTiming("ble_chars_ready")
|
|
1747
2369
|
Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
|
|
1748
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
|
+
|
|
1749
2382
|
// Don't set connected=true here - wait for SOC to be ready
|
|
1750
2383
|
// (fullyBooted=true)
|
|
1751
2384
|
// DeviceStore handles connected state based on fullyBooted
|
|
@@ -1786,13 +2419,6 @@ class MentraLive : SGCManager() {
|
|
|
1786
2419
|
if (txCharacteristic == null) {
|
|
1787
2420
|
Log.e(TAG, "TX characteristic (peripheral's RX) not found")
|
|
1788
2421
|
}
|
|
1789
|
-
// Log LC3 characteristic errors
|
|
1790
|
-
if (lc3ReadCharacteristic == null) {
|
|
1791
|
-
Log.e(TAG, "LC3_READ characteristic not found")
|
|
1792
|
-
}
|
|
1793
|
-
if (lc3WriteCharacteristic == null) {
|
|
1794
|
-
Log.e(TAG, "LC3_WRITE characteristic not found")
|
|
1795
|
-
}
|
|
1796
2422
|
gatt.disconnect()
|
|
1797
2423
|
}
|
|
1798
2424
|
} else {
|
|
@@ -1899,6 +2525,10 @@ class MentraLive : SGCManager() {
|
|
|
1899
2525
|
gatt: BluetoothGatt,
|
|
1900
2526
|
characteristic: BluetoothGattCharacteristic
|
|
1901
2527
|
) {
|
|
2528
|
+
if (!isCurrentGattCallback(gatt)) {
|
|
2529
|
+
return
|
|
2530
|
+
}
|
|
2531
|
+
|
|
1902
2532
|
// Get thread ID for tracking thread issues
|
|
1903
2533
|
val threadId = Thread.currentThread().id
|
|
1904
2534
|
val uuid = characteristic.uuid
|
|
@@ -1932,20 +2562,6 @@ class MentraLive : SGCManager() {
|
|
|
1932
2562
|
|
|
1933
2563
|
if (isRxCharacteristic) {
|
|
1934
2564
|
Bridge.log("LIVE: Received data on RX characteristic")
|
|
1935
|
-
// #region agent log [810da2] Hypothesis A+C: capture data.length vs
|
|
1936
|
-
// negotiated MTU
|
|
1937
|
-
Bridge.log(
|
|
1938
|
-
"LIVE: [DEBUG-810da2-HypAC] RX dataLen=" +
|
|
1939
|
-
data.size +
|
|
1940
|
-
" mtu=" +
|
|
1941
|
-
currentMtu +
|
|
1942
|
-
" firstByte=0x" +
|
|
1943
|
-
String.format("%02X", data[0]) +
|
|
1944
|
-
" second=0x" +
|
|
1945
|
-
(if (data.size > 1) String.format("%02X", data[1])
|
|
1946
|
-
else "??")
|
|
1947
|
-
)
|
|
1948
|
-
// #endregion
|
|
1949
2565
|
} else if (isTxCharacteristic) {
|
|
1950
2566
|
Bridge.log("LIVE: Received data on TX characteristic")
|
|
1951
2567
|
} else if (isLc3ReadCharacteristic) {
|
|
@@ -2050,6 +2666,9 @@ class MentraLive : SGCManager() {
|
|
|
2050
2666
|
startSignalStrengthPolling()
|
|
2051
2667
|
handler.post(processSendQueueRunnable!!)
|
|
2052
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()
|
|
2053
2672
|
return
|
|
2054
2673
|
}
|
|
2055
2674
|
|
|
@@ -2310,8 +2929,20 @@ class MentraLive : SGCManager() {
|
|
|
2310
2929
|
return
|
|
2311
2930
|
}
|
|
2312
2931
|
|
|
2313
|
-
// Send the next item from
|
|
2314
|
-
|
|
2932
|
+
// Send the next item from this BLE session. A retry can race disconnect teardown and land
|
|
2933
|
+
// after sendQueue.clear(); the generation check is the deterministic backstop.
|
|
2934
|
+
var queuedWrite = sendQueue.poll()
|
|
2935
|
+
val currentGeneration = bleSessionGeneration.get()
|
|
2936
|
+
while (queuedWrite != null && queuedWrite.sessionGeneration != currentGeneration) {
|
|
2937
|
+
Bridge.log(
|
|
2938
|
+
"LIVE: Dropping stale queued write from BLE session " +
|
|
2939
|
+
queuedWrite.sessionGeneration +
|
|
2940
|
+
" (current: " +
|
|
2941
|
+
currentGeneration +
|
|
2942
|
+
")"
|
|
2943
|
+
)
|
|
2944
|
+
queuedWrite = sendQueue.poll()
|
|
2945
|
+
}
|
|
2315
2946
|
if (queuedWrite != null) {
|
|
2316
2947
|
// Update last send time before sending
|
|
2317
2948
|
lastSendTimeMs = currentTimeMs
|
|
@@ -2338,25 +2969,35 @@ class MentraLive : SGCManager() {
|
|
|
2338
2969
|
|
|
2339
2970
|
/** Send data through BLE */
|
|
2340
2971
|
private fun sendDataInternal(write: QueuedBleWrite?) {
|
|
2341
|
-
if (!isConnected ||
|
|
2972
|
+
if (!isConnected || write == null) {
|
|
2973
|
+
return
|
|
2974
|
+
}
|
|
2975
|
+
if (write.sessionGeneration != bleSessionGeneration.get()) {
|
|
2976
|
+
Bridge.log("LIVE: Dropping stale BLE write before GATT transmission")
|
|
2977
|
+
return
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
val gatt = bluetoothGatt
|
|
2981
|
+
val characteristic = txCharacteristic
|
|
2982
|
+
if (gatt == null || characteristic == null) {
|
|
2342
2983
|
return
|
|
2343
2984
|
}
|
|
2344
2985
|
|
|
2345
2986
|
try {
|
|
2346
2987
|
val writeStartedAtMs = System.currentTimeMillis()
|
|
2347
|
-
|
|
2988
|
+
characteristic.value = write.data
|
|
2348
2989
|
inFlightBleWriteTrace = write.trace
|
|
2349
2990
|
inFlightBleWriteStartedAtMs = writeStartedAtMs
|
|
2350
|
-
val writeAccepted =
|
|
2991
|
+
val writeAccepted = gatt.writeCharacteristic(characteristic)
|
|
2351
2992
|
logBleWriteTrace(
|
|
2352
2993
|
"write_call",
|
|
2353
2994
|
write.trace,
|
|
2354
2995
|
mapOf(
|
|
2355
2996
|
"writeAccepted" to writeAccepted,
|
|
2356
2997
|
"currentMtu" to currentMtu,
|
|
2357
|
-
"writeType" to
|
|
2998
|
+
"writeType" to characteristic.writeType,
|
|
2358
2999
|
"queueSize" to sendQueue.size,
|
|
2359
|
-
"characteristicUuid" to
|
|
3000
|
+
"characteristicUuid" to characteristic.uuid.toString()
|
|
2360
3001
|
)
|
|
2361
3002
|
)
|
|
2362
3003
|
if (!writeAccepted) {
|
|
@@ -2379,7 +3020,11 @@ class MentraLive : SGCManager() {
|
|
|
2379
3020
|
}
|
|
2380
3021
|
|
|
2381
3022
|
/** Queue data to be sent */
|
|
2382
|
-
private fun queueData(
|
|
3023
|
+
private fun queueData(
|
|
3024
|
+
data: ByteArray?,
|
|
3025
|
+
trace: BleWriteTrace? = null,
|
|
3026
|
+
sessionGeneration: Long = bleSessionGeneration.get()
|
|
3027
|
+
) {
|
|
2383
3028
|
if (data != null) {
|
|
2384
3029
|
val queuedTrace =
|
|
2385
3030
|
trace?.copy(
|
|
@@ -2388,7 +3033,7 @@ class MentraLive : SGCManager() {
|
|
|
2388
3033
|
trace.queuedAtMs
|
|
2389
3034
|
else System.currentTimeMillis()
|
|
2390
3035
|
)
|
|
2391
|
-
sendQueue.add(QueuedBleWrite(data, queuedTrace))
|
|
3036
|
+
sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
|
|
2392
3037
|
logBleChunkTrace(
|
|
2393
3038
|
"queued",
|
|
2394
3039
|
queuedTrace,
|
|
@@ -2432,6 +3077,7 @@ class MentraLive : SGCManager() {
|
|
|
2432
3077
|
private fun sendJson(json: JSONObject?, wakeup: Boolean) {
|
|
2433
3078
|
if (json != null) {
|
|
2434
3079
|
try {
|
|
3080
|
+
val sessionGeneration = bleSessionGeneration.get()
|
|
2435
3081
|
if (buildNumberInt < 5) {
|
|
2436
3082
|
val jsonStr = json.toString()
|
|
2437
3083
|
// Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
|
|
@@ -2447,7 +3093,7 @@ class MentraLive : SGCManager() {
|
|
|
2447
3093
|
json,
|
|
2448
3094
|
jsonStr.length
|
|
2449
3095
|
)
|
|
2450
|
-
sendDataToGlasses(jsonStr, wakeup)
|
|
3096
|
+
sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
|
|
2451
3097
|
} else {
|
|
2452
3098
|
// Add esoteric message ID to the JSON
|
|
2453
3099
|
val messageId = generateEsotericMessageId()
|
|
@@ -2489,7 +3135,7 @@ class MentraLive : SGCManager() {
|
|
|
2489
3135
|
}
|
|
2490
3136
|
|
|
2491
3137
|
// Track the message for ACK with appropriate timeout
|
|
2492
|
-
trackMessageForAck(messageId, jsonStr, ackTimeout)
|
|
3138
|
+
trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
|
|
2493
3139
|
|
|
2494
3140
|
// Send the data
|
|
2495
3141
|
if ("take_photo" == json.optString("type", "")) {
|
|
@@ -2508,7 +3154,7 @@ class MentraLive : SGCManager() {
|
|
|
2508
3154
|
json,
|
|
2509
3155
|
jsonStr.length
|
|
2510
3156
|
)
|
|
2511
|
-
sendDataToGlasses(jsonStr, wakeup)
|
|
3157
|
+
sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
|
|
2512
3158
|
}
|
|
2513
3159
|
} catch (e: JSONException) {
|
|
2514
3160
|
Log.e(TAG, "Error adding message ID to JSON", e)
|
|
@@ -2528,14 +3174,14 @@ class MentraLive : SGCManager() {
|
|
|
2528
3174
|
return list
|
|
2529
3175
|
}
|
|
2530
3176
|
|
|
2531
|
-
/** Track a message for ACK response */
|
|
2532
|
-
private fun trackMessageForAck(messageId: Long, messageData: String) {
|
|
2533
|
-
trackMessageForAck(messageId, messageData, ACK_TIMEOUT_MS)
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
3177
|
/** Track a message for ACK response with custom timeout */
|
|
2537
|
-
private fun trackMessageForAck(
|
|
2538
|
-
|
|
3178
|
+
private fun trackMessageForAck(
|
|
3179
|
+
messageId: Long,
|
|
3180
|
+
messageData: String,
|
|
3181
|
+
timeoutMs: Long,
|
|
3182
|
+
sessionGeneration: Long
|
|
3183
|
+
) {
|
|
3184
|
+
if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
|
|
2539
3185
|
Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
|
|
2540
3186
|
return
|
|
2541
3187
|
}
|
|
@@ -2552,25 +3198,50 @@ class MentraLive : SGCManager() {
|
|
|
2552
3198
|
}
|
|
2553
3199
|
|
|
2554
3200
|
// Create retry runnable
|
|
2555
|
-
val retryRunnable = Runnable { retryMessage(messageId) }
|
|
3201
|
+
val retryRunnable = Runnable { retryMessage(messageId, sessionGeneration) }
|
|
2556
3202
|
|
|
2557
3203
|
// Create pending message
|
|
2558
3204
|
val pendingMessage =
|
|
2559
|
-
PendingMessage(
|
|
3205
|
+
PendingMessage(
|
|
3206
|
+
messageData,
|
|
3207
|
+
System.currentTimeMillis(),
|
|
3208
|
+
0,
|
|
3209
|
+
retryRunnable,
|
|
3210
|
+
sessionGeneration
|
|
3211
|
+
)
|
|
2560
3212
|
pendingMessages[messageId] = pendingMessage
|
|
2561
3213
|
|
|
2562
3214
|
// Schedule ACK timeout with custom timeout
|
|
2563
|
-
handler.postDelayed({ checkMessageAck(messageId) }, timeoutMs)
|
|
3215
|
+
handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
|
|
2564
3216
|
|
|
2565
3217
|
Bridge.log(
|
|
2566
3218
|
"LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
|
|
2567
3219
|
)
|
|
2568
3220
|
}
|
|
2569
3221
|
|
|
3222
|
+
/**
|
|
3223
|
+
* Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
|
|
3224
|
+
* the old generation, and queued retries retain it, so a retry racing this clear cannot become
|
|
3225
|
+
* valid in the next session.
|
|
3226
|
+
*/
|
|
3227
|
+
private fun resetPendingAckState() {
|
|
3228
|
+
val nextGeneration = bleSessionGeneration.incrementAndGet()
|
|
3229
|
+
val pendingCount = pendingMessages.size
|
|
3230
|
+
pendingMessages.clear()
|
|
3231
|
+
if (pendingCount > 0) {
|
|
3232
|
+
Bridge.log(
|
|
3233
|
+
"LIVE: Cleared $pendingCount pending ACK message(s); BLE session is now $nextGeneration"
|
|
3234
|
+
)
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
2570
3238
|
/** Check if a message has been acknowledged */
|
|
2571
|
-
private fun checkMessageAck(messageId: Long) {
|
|
3239
|
+
private fun checkMessageAck(messageId: Long, sessionGeneration: Long) {
|
|
2572
3240
|
val pendingMessage = pendingMessages[messageId]
|
|
2573
|
-
if (pendingMessage != null
|
|
3241
|
+
if (pendingMessage != null &&
|
|
3242
|
+
pendingMessage.sessionGeneration == sessionGeneration &&
|
|
3243
|
+
sessionGeneration == bleSessionGeneration.get()
|
|
3244
|
+
) {
|
|
2574
3245
|
Log.w(
|
|
2575
3246
|
TAG,
|
|
2576
3247
|
"⏰ ACK timeout for message " +
|
|
@@ -2591,7 +3262,7 @@ class MentraLive : SGCManager() {
|
|
|
2591
3262
|
MAX_RETRY_ATTEMPTS +
|
|
2592
3263
|
")"
|
|
2593
3264
|
)
|
|
2594
|
-
retryMessage(messageId)
|
|
3265
|
+
retryMessage(messageId, sessionGeneration)
|
|
2595
3266
|
} else {
|
|
2596
3267
|
// Max retries reached
|
|
2597
3268
|
Log.e(
|
|
@@ -2602,22 +3273,31 @@ class MentraLive : SGCManager() {
|
|
|
2602
3273
|
MAX_RETRY_ATTEMPTS +
|
|
2603
3274
|
" attempts"
|
|
2604
3275
|
)
|
|
2605
|
-
pendingMessages.remove(messageId)
|
|
3276
|
+
pendingMessages.remove(messageId, pendingMessage)
|
|
2606
3277
|
}
|
|
3278
|
+
} else if (pendingMessage != null && pendingMessage.sessionGeneration == sessionGeneration) {
|
|
3279
|
+
pendingMessages.remove(messageId, pendingMessage)
|
|
2607
3280
|
}
|
|
2608
3281
|
}
|
|
2609
3282
|
|
|
2610
3283
|
/** Retry a message */
|
|
2611
|
-
private fun retryMessage(messageId: Long) {
|
|
3284
|
+
private fun retryMessage(messageId: Long, sessionGeneration: Long) {
|
|
2612
3285
|
val pendingMessage = pendingMessages[messageId]
|
|
2613
3286
|
if (pendingMessage == null) {
|
|
2614
3287
|
Log.w(TAG, "Message " + messageId + " no longer tracked for retry")
|
|
2615
3288
|
return
|
|
2616
3289
|
}
|
|
3290
|
+
if (pendingMessage.sessionGeneration != sessionGeneration ||
|
|
3291
|
+
sessionGeneration != bleSessionGeneration.get()
|
|
3292
|
+
) {
|
|
3293
|
+
pendingMessages.remove(messageId, pendingMessage)
|
|
3294
|
+
Bridge.log("LIVE: Dropping stale ACK retry for message $messageId")
|
|
3295
|
+
return
|
|
3296
|
+
}
|
|
2617
3297
|
|
|
2618
3298
|
if (pendingMessage.retryCount >= MAX_RETRY_ATTEMPTS) {
|
|
2619
3299
|
Log.e(TAG, "Max retries reached for message " + messageId)
|
|
2620
|
-
pendingMessages.remove(messageId)
|
|
3300
|
+
pendingMessages.remove(messageId, pendingMessage)
|
|
2621
3301
|
return
|
|
2622
3302
|
}
|
|
2623
3303
|
|
|
@@ -2627,11 +3307,18 @@ class MentraLive : SGCManager() {
|
|
|
2627
3307
|
pendingMessage.messageData,
|
|
2628
3308
|
System.currentTimeMillis(),
|
|
2629
3309
|
pendingMessage.retryCount + 1,
|
|
2630
|
-
pendingMessage.retryRunnable
|
|
3310
|
+
pendingMessage.retryRunnable,
|
|
3311
|
+
sessionGeneration
|
|
2631
3312
|
)
|
|
2632
3313
|
|
|
2633
|
-
//
|
|
2634
|
-
pendingMessages
|
|
3314
|
+
// Replace only the entry inspected above. An ACK or disconnect may have removed it.
|
|
3315
|
+
if (!pendingMessages.replace(messageId, pendingMessage, retryMessage)) {
|
|
3316
|
+
return
|
|
3317
|
+
}
|
|
3318
|
+
if (sessionGeneration != bleSessionGeneration.get()) {
|
|
3319
|
+
pendingMessages.remove(messageId, retryMessage)
|
|
3320
|
+
return
|
|
3321
|
+
}
|
|
2635
3322
|
|
|
2636
3323
|
// Send the message again
|
|
2637
3324
|
Bridge.log(
|
|
@@ -2641,10 +3328,10 @@ class MentraLive : SGCManager() {
|
|
|
2641
3328
|
retryMessage.retryCount +
|
|
2642
3329
|
")"
|
|
2643
3330
|
)
|
|
2644
|
-
sendDataToGlasses(pendingMessage.messageData, false)
|
|
3331
|
+
sendDataToGlasses(pendingMessage.messageData, false, sessionGeneration)
|
|
2645
3332
|
|
|
2646
3333
|
// Schedule next ACK check
|
|
2647
|
-
handler.postDelayed({ checkMessageAck(messageId) }, ACK_TIMEOUT_MS)
|
|
3334
|
+
handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, ACK_TIMEOUT_MS)
|
|
2648
3335
|
}
|
|
2649
3336
|
|
|
2650
3337
|
/** Process ACK response from glasses */
|
|
@@ -2955,25 +3642,6 @@ class MentraLive : SGCManager() {
|
|
|
2955
3642
|
processJsonMessage(expanded)
|
|
2956
3643
|
} else {
|
|
2957
3644
|
Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
|
|
2958
|
-
// #region agent log [810da2] Hypothesis A+B: log header-declared length vs actual
|
|
2959
|
-
// data length
|
|
2960
|
-
val declaredPayloadLen =
|
|
2961
|
-
if (data.size >= 5)
|
|
2962
|
-
(((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF))
|
|
2963
|
-
else -1
|
|
2964
|
-
Bridge.log(
|
|
2965
|
-
"LIVE: [DEBUG-810da2-HypAB] K900 PARSE FAILED thread=" +
|
|
2966
|
-
threadId +
|
|
2967
|
-
" dataLen=" +
|
|
2968
|
-
data.size +
|
|
2969
|
-
" mtu=" +
|
|
2970
|
-
currentMtu +
|
|
2971
|
-
" declaredPayloadLen=" +
|
|
2972
|
-
declaredPayloadLen +
|
|
2973
|
-
" expectedTotal=" +
|
|
2974
|
-
(declaredPayloadLen + 7)
|
|
2975
|
-
)
|
|
2976
|
-
// #endregion
|
|
2977
3645
|
}
|
|
2978
3646
|
|
|
2979
3647
|
return // Exit after processing K900 protocol format
|
|
@@ -3147,6 +3815,24 @@ class MentraLive : SGCManager() {
|
|
|
3147
3815
|
"pong" ->
|
|
3148
3816
|
// Process heartbeat pong response
|
|
3149
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
|
+
)
|
|
3150
3836
|
"imu_response",
|
|
3151
3837
|
"imu_stream_response",
|
|
3152
3838
|
"imu_gesture_response",
|
|
@@ -3328,6 +4014,7 @@ class MentraLive : SGCManager() {
|
|
|
3328
4014
|
if (osStepSequence != null && osStepSequence.length() > 0) {
|
|
3329
4015
|
cachedOtaStepSequence = osStepSequence
|
|
3330
4016
|
}
|
|
4017
|
+
updateBesOtaHeartbeatGuard(osStepType, osPhase, osStatus)
|
|
3331
4018
|
|
|
3332
4019
|
Bridge.log(
|
|
3333
4020
|
"LIVE: 📱 OTA status - step " +
|
|
@@ -3378,6 +4065,7 @@ class MentraLive : SGCManager() {
|
|
|
3378
4065
|
} else {
|
|
3379
4066
|
unified = "in_progress"
|
|
3380
4067
|
}
|
|
4068
|
+
updateBesOtaHeartbeatGuard(currentUpdate, legacyPhase, unified)
|
|
3381
4069
|
Bridge.log(
|
|
3382
4070
|
"LIVE: 📱 Legacy ota_progress → ota_status: " +
|
|
3383
4071
|
legacyStage +
|
|
@@ -3533,7 +4221,19 @@ class MentraLive : SGCManager() {
|
|
|
3533
4221
|
}
|
|
3534
4222
|
"glasses_ready" -> {
|
|
3535
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
|
+
}
|
|
3536
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
|
+
)
|
|
3537
4237
|
|
|
3538
4238
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
3539
4239
|
// onTransportReset() before sending it, so their side is back on legacy
|
|
@@ -3552,9 +4252,9 @@ class MentraLive : SGCManager() {
|
|
|
3552
4252
|
// Set the ready flag to stop any future readiness checks
|
|
3553
4253
|
glassesReady = true
|
|
3554
4254
|
glassesReadyReceived = true
|
|
3555
|
-
//
|
|
3556
|
-
//
|
|
3557
|
-
//
|
|
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).
|
|
3558
4258
|
|
|
3559
4259
|
// Stop the readiness check loop since we got confirmation
|
|
3560
4260
|
stopReadinessCheckLoop()
|
|
@@ -3674,19 +4374,21 @@ class MentraLive : SGCManager() {
|
|
|
3674
4374
|
Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
|
|
3675
4375
|
}
|
|
3676
4376
|
|
|
3677
|
-
//
|
|
3678
|
-
//
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
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) {
|
|
3682
4386
|
Bridge.log(
|
|
3683
|
-
"LIVE: Audio:
|
|
4387
|
+
"LIVE: Audio: CTKD Classic bonding still in progress (pairing not gated)"
|
|
3684
4388
|
)
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
Bridge.log(
|
|
3689
|
-
"LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"
|
|
4389
|
+
markPairingTiming(
|
|
4390
|
+
"waiting_ctkd_bond",
|
|
4391
|
+
"queueSize=${sendQueue.size}"
|
|
3690
4392
|
)
|
|
3691
4393
|
}
|
|
3692
4394
|
}
|
|
@@ -4000,6 +4702,9 @@ class MentraLive : SGCManager() {
|
|
|
4000
4702
|
(fields["bt_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4001
4703
|
DeviceStore.apply("glasses", "bluetoothMacAddress", it)
|
|
4002
4704
|
}
|
|
4705
|
+
(fields["wifi_mac_address"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4706
|
+
DeviceStore.apply("glasses", "wifiMacAddress", it)
|
|
4707
|
+
}
|
|
4003
4708
|
(fields["serial_number"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
|
4004
4709
|
DeviceStore.apply("glasses", "serialNumber", it)
|
|
4005
4710
|
}
|
|
@@ -4009,6 +4714,12 @@ class MentraLive : SGCManager() {
|
|
|
4009
4714
|
DeviceStore.apply("glasses", "systemTimeMs", v.toLong())
|
|
4010
4715
|
}
|
|
4011
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
|
+
}
|
|
4012
4723
|
|
|
4013
4724
|
// Negotiate wire caps from ANY version_info chunk, not only the one
|
|
4014
4725
|
// carrying build_number: the glasses put wire_caps in version_info_3
|
|
@@ -4365,6 +5076,10 @@ class MentraLive : SGCManager() {
|
|
|
4365
5076
|
}
|
|
4366
5077
|
if (ready == 1) {
|
|
4367
5078
|
Bridge.log("LIVE: K900 SOC ready")
|
|
5079
|
+
markPairingTiming(
|
|
5080
|
+
"soc_ready",
|
|
5081
|
+
"glassesReady=$glassesReady queueSize=${sendQueue.size}"
|
|
5082
|
+
)
|
|
4368
5083
|
// Only send phone_ready if we haven't already established connection
|
|
4369
5084
|
// This prevents re-initialization on every heartbeat after initial
|
|
4370
5085
|
// connection
|
|
@@ -4468,15 +5183,28 @@ class MentraLive : SGCManager() {
|
|
|
4468
5183
|
var progress = ((rawProgress + 2) / 5) * 5
|
|
4469
5184
|
if (progress > 100) progress = 100
|
|
4470
5185
|
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
5186
|
+
val mapping =
|
|
5187
|
+
mapBesOtaProgress(
|
|
5188
|
+
type,
|
|
5189
|
+
rawProgress,
|
|
5190
|
+
bodyObj.optString("message", "BES update failed"),
|
|
5191
|
+
)
|
|
5192
|
+
val besOtaStatus = mapping.status
|
|
5193
|
+
val besOtaProgressVal = mapping.progress
|
|
5194
|
+
val besOtaErrorMessage = mapping.errorMessage
|
|
5195
|
+
|
|
5196
|
+
if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
|
|
5197
|
+
besOtaHeartbeatGuard.clear()
|
|
5198
|
+
} else {
|
|
5199
|
+
// Refresh before progress de-duplication: raw progress proves the BES
|
|
5200
|
+
// transfer is active even when the rounded UI value is unchanged.
|
|
5201
|
+
besOtaHeartbeatGuard.refresh(SystemClock.elapsedRealtime())
|
|
5202
|
+
}
|
|
5203
|
+
|
|
5204
|
+
// Only send if nonterminal display progress changed to a new 5% increment.
|
|
5205
|
+
if ("PROGRESS" == besOtaStatus && besOtaProgressVal == lastBesOtaProgress) {
|
|
5206
|
+
return
|
|
4478
5207
|
}
|
|
4479
|
-
lastBesOtaProgress = progress
|
|
4480
5208
|
|
|
4481
5209
|
Bridge.log(
|
|
4482
5210
|
"LIVE: 📱 BES OTA progress via sr_adota - type: " +
|
|
@@ -4485,58 +5213,21 @@ class MentraLive : SGCManager() {
|
|
|
4485
5213
|
rawProgress +
|
|
4486
5214
|
"%, rounded: " +
|
|
4487
5215
|
progress +
|
|
5216
|
+
"%, display: " +
|
|
5217
|
+
besOtaProgressVal +
|
|
4488
5218
|
"%"
|
|
4489
5219
|
)
|
|
4490
5220
|
|
|
4491
|
-
|
|
4492
|
-
val besOtaStatus: String
|
|
4493
|
-
val besOtaProgressVal: Int
|
|
4494
|
-
var besOtaErrorMessage: String? = null
|
|
4495
|
-
|
|
4496
|
-
// Order matters here: check completion (rawProgress >= 100 OR success)
|
|
4497
|
-
// BEFORE
|
|
4498
|
-
// type=="update", because some BES firmware emits the final 100% tick with
|
|
4499
|
-
// type=="update" rather than type=="success". Treating that as PROGRESS
|
|
4500
|
-
// would
|
|
4501
|
-
// leave the UI stuck at 100% forever.
|
|
4502
|
-
if ("success" == type || rawProgress >= 100) {
|
|
4503
|
-
besOtaStatus = "FINISHED"
|
|
4504
|
-
besOtaProgressVal = 100
|
|
4505
|
-
lastBesOtaProgress = -1 // Reset for next OTA
|
|
4506
|
-
} else if ("error" == type || "fail" == type) {
|
|
4507
|
-
besOtaStatus = "FAILED"
|
|
4508
|
-
besOtaProgressVal = progress
|
|
4509
|
-
besOtaErrorMessage = bodyObj.optString("message", "BES update failed")
|
|
5221
|
+
if ("FINISHED" == besOtaStatus || "FAILED" == besOtaStatus) {
|
|
4510
5222
|
lastBesOtaProgress = -1 // Reset for next OTA
|
|
4511
|
-
} else if ("update" == type) {
|
|
4512
|
-
besOtaStatus = "PROGRESS"
|
|
4513
|
-
besOtaProgressVal = progress
|
|
4514
5223
|
} else {
|
|
4515
|
-
|
|
4516
|
-
besOtaStatus = "PROGRESS"
|
|
4517
|
-
besOtaProgressVal = progress
|
|
5224
|
+
lastBesOtaProgress = besOtaProgressVal
|
|
4518
5225
|
}
|
|
4519
5226
|
|
|
4520
|
-
val syntheticStatus
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
// ota_status from the glasses — consumers mapping on this synthetic
|
|
4525
|
-
// status would otherwise never see a terminal state. Emit "complete"
|
|
4526
|
-
// for the final step; mid-session BES steps keep "step_complete" so
|
|
4527
|
-
// session-level trackers advance normally. Unknown sessions
|
|
4528
|
-
// (cachedOtaTotalSteps == 0, e.g. legacy glasses that never sent an
|
|
4529
|
-
// ota_status) conservatively keep "step_complete".
|
|
4530
|
-
syntheticStatus =
|
|
4531
|
-
if (cachedOtaTotalSteps > 0 &&
|
|
4532
|
-
cachedOtaCurrentStep >= cachedOtaTotalSteps
|
|
4533
|
-
)
|
|
4534
|
-
"complete"
|
|
4535
|
-
else "step_complete"
|
|
4536
|
-
} else if ("FAILED" == besOtaStatus) {
|
|
4537
|
-
syntheticStatus = "failed"
|
|
4538
|
-
} else {
|
|
4539
|
-
syntheticStatus = "in_progress"
|
|
5227
|
+
val syntheticStatus = when (besOtaStatus) {
|
|
5228
|
+
"FINISHED" -> "step_complete"
|
|
5229
|
+
"FAILED" -> "failed"
|
|
5230
|
+
else -> "in_progress"
|
|
4540
5231
|
}
|
|
4541
5232
|
val sid = if (cachedOtaSessionId != null) cachedOtaSessionId!! else ""
|
|
4542
5233
|
val totalSteps = if (cachedOtaTotalSteps > 0) cachedOtaTotalSteps else 1
|
|
@@ -5001,6 +5692,10 @@ class MentraLive : SGCManager() {
|
|
|
5001
5692
|
Bridge.log("LIVE: Skipping heartbeat - glasses not ready or not connected")
|
|
5002
5693
|
return
|
|
5003
5694
|
}
|
|
5695
|
+
if (besOtaHeartbeatGuard.shouldSuppress(SystemClock.elapsedRealtime())) {
|
|
5696
|
+
Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
|
|
5697
|
+
return
|
|
5698
|
+
}
|
|
5004
5699
|
|
|
5005
5700
|
try {
|
|
5006
5701
|
// Send ping message (no ACK needed for heartbeats)
|
|
@@ -5092,6 +5787,8 @@ class MentraLive : SGCManager() {
|
|
|
5092
5787
|
|
|
5093
5788
|
/** Start the micbeat mechanism - periodically enable custom audio TX */
|
|
5094
5789
|
private fun startMicBeat() {
|
|
5790
|
+
// A resumed custom-audio stream starts a new sequence domain.
|
|
5791
|
+
DeviceManager.getInstance().resetMicSequenceBaseline()
|
|
5095
5792
|
micOnCount++
|
|
5096
5793
|
Bridge.log("LIVE: 🎤 Mic ON/OFF count: " + micOnCount + " on, " + micOffCount + " off")
|
|
5097
5794
|
micBeatCount = 0
|
|
@@ -5233,8 +5930,16 @@ class MentraLive : SGCManager() {
|
|
|
5233
5930
|
override fun findCompatibleDevices() {
|
|
5234
5931
|
Bridge.log("LIVE: Finding compatible Mentra Live glasses")
|
|
5235
5932
|
|
|
5236
|
-
//
|
|
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
|
|
5237
5940
|
isReconnecting = false
|
|
5941
|
+
// Discovery-only: do not treat leftover savedDeviceName as a connect target.
|
|
5942
|
+
explicitConnectByName = false
|
|
5238
5943
|
|
|
5239
5944
|
if (bluetoothAdapter == null) {
|
|
5240
5945
|
Log.e(TAG, "Bluetooth not available")
|
|
@@ -5246,50 +5951,124 @@ class MentraLive : SGCManager() {
|
|
|
5246
5951
|
return
|
|
5247
5952
|
}
|
|
5248
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
|
+
|
|
5249
5959
|
// Start scanning for BLE devices
|
|
5250
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
|
+
}
|
|
5251
5992
|
}
|
|
5252
5993
|
|
|
5253
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
|
|
5254
6005
|
Bridge.log("LIVE: Connecting to Mentra Live glasses by ID: " + id)
|
|
5255
6006
|
savedDeviceName = id
|
|
5256
|
-
//
|
|
5257
|
-
//
|
|
5258
|
-
|
|
5259
|
-
// Log.i(TAG, "🔌 💾 Saved device name for future reconnection: " +
|
|
5260
|
-
// connectedDevice.getName());
|
|
5261
|
-
// Bridge.log("LIVE: Saved device name for future reconnection: " +
|
|
5262
|
-
// connectedDevice.getName());
|
|
6007
|
+
// Without this, connectToSmartGlasses() falls back to a name scan with
|
|
6008
|
+
// isReconnecting=false and never GATT-connects (loading screen hangs).
|
|
6009
|
+
explicitConnectByName = true
|
|
5263
6010
|
connectToSmartGlasses()
|
|
5264
6011
|
}
|
|
5265
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
|
+
|
|
5266
6023
|
override fun forget() {
|
|
5267
6024
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
5268
6025
|
|
|
5269
|
-
// Clear saved device name
|
|
5270
|
-
//
|
|
5271
|
-
|
|
5272
|
-
// 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 = ""
|
|
5273
6029
|
|
|
5274
6030
|
// Reset reconnection state
|
|
5275
6031
|
reconnectAttempts = 0
|
|
5276
6032
|
isReconnecting = false
|
|
6033
|
+
manualDiscoveryActive = false
|
|
6034
|
+
explicitConnectByName = false
|
|
5277
6035
|
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
if (connectedDevice != null) {
|
|
5281
|
-
Bridge.log("LIVE: CTKD: Removing BT bond on explicit unpair")
|
|
5282
|
-
removeBond(connectedDevice!!)
|
|
5283
|
-
}
|
|
5284
|
-
|
|
6036
|
+
val ctx = context ?: Bridge.getContext()
|
|
6037
|
+
val bondedAddress = connectedDevice?.address
|
|
5285
6038
|
if (isScanning) {
|
|
5286
6039
|
stopScan()
|
|
5287
6040
|
emitStopScanEvent()
|
|
5288
6041
|
}
|
|
5289
|
-
|
|
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)
|
|
5290
6065
|
}
|
|
5291
6066
|
|
|
5292
6067
|
override fun disconnect() {
|
|
6068
|
+
if (unpairFlushPending) {
|
|
6069
|
+
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
6070
|
+
return
|
|
6071
|
+
}
|
|
5293
6072
|
Bridge.log("LIVE: Disconnecting from Mentra Live glasses")
|
|
5294
6073
|
destroy()
|
|
5295
6074
|
}
|
|
@@ -5327,6 +6106,10 @@ class MentraLive : SGCManager() {
|
|
|
5327
6106
|
}
|
|
5328
6107
|
|
|
5329
6108
|
fun connectToSmartGlasses() {
|
|
6109
|
+
if (pairingYieldActive) {
|
|
6110
|
+
Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
|
|
6111
|
+
return
|
|
6112
|
+
}
|
|
5330
6113
|
Bridge.log("LIVE: Connecting to Mentra Live glasses")
|
|
5331
6114
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
5332
6115
|
|
|
@@ -5355,7 +6138,13 @@ class MentraLive : SGCManager() {
|
|
|
5355
6138
|
// var context = Bridge.getContext();
|
|
5356
6139
|
// SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
5357
6140
|
// String lastDeviceAddress = prefs.getString(PREF_DEVICE_NAME, null);
|
|
5358
|
-
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?)
|
|
5359
6148
|
|
|
5360
6149
|
if (lastDeviceAddress != null && lastDeviceAddress.length > 0) {
|
|
5361
6150
|
// Connect to last known device if available
|
|
@@ -5366,6 +6155,7 @@ class MentraLive : SGCManager() {
|
|
|
5366
6155
|
Bridge.log(
|
|
5367
6156
|
"LIVE: Found saved device, connecting directly: " + lastDeviceAddress
|
|
5368
6157
|
)
|
|
6158
|
+
prepareClassicBeforeBleConnect(device)
|
|
5369
6159
|
connectToDevice(device)
|
|
5370
6160
|
} else {
|
|
5371
6161
|
Bridge.log(
|
|
@@ -5381,12 +6171,62 @@ class MentraLive : SGCManager() {
|
|
|
5381
6171
|
startScan() // Fallback to scanning
|
|
5382
6172
|
}
|
|
5383
6173
|
} else {
|
|
5384
|
-
//
|
|
5385
|
-
|
|
5386
|
-
|
|
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
|
+
}
|
|
6191
|
+
}
|
|
6192
|
+
}
|
|
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
|
|
5387
6206
|
}
|
|
5388
6207
|
}
|
|
5389
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
|
+
|
|
5390
6230
|
override fun setMicEnabled(enabled: Boolean) {
|
|
5391
6231
|
Bridge.log("LIVE: 🎤 Microphone state change requested: " + enabled)
|
|
5392
6232
|
|
|
@@ -5830,41 +6670,67 @@ class MentraLive : SGCManager() {
|
|
|
5830
6670
|
Bridge.log(
|
|
5831
6671
|
"LIVE: CTKD: ✅ Successfully bonded with device - BT Classic connection established"
|
|
5832
6672
|
)
|
|
6673
|
+
markPairingTiming(
|
|
6674
|
+
"ctkd_bonded",
|
|
6675
|
+
"glassesReadyReceived=$glassesReadyReceived queueSize=${sendQueue.size}"
|
|
6676
|
+
)
|
|
5833
6677
|
if (isKilled) {
|
|
5834
6678
|
Bridge.log(
|
|
5835
6679
|
"LIVE: CTKD: Ignoring bond complete — SGC destroyed"
|
|
5836
6680
|
)
|
|
5837
6681
|
return@run
|
|
5838
6682
|
}
|
|
5839
|
-
isBtClassicConnected = true
|
|
5840
|
-
audioConnected = true
|
|
5841
6683
|
bondingRetryCount =
|
|
5842
6684
|
0 // Reset retry counter on success
|
|
5843
|
-
//
|
|
5844
|
-
//
|
|
5845
|
-
|
|
5846
|
-
//
|
|
5847
|
-
//
|
|
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.
|
|
5848
6694
|
if (glassesReadyReceived) {
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
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
|
+
}
|
|
5858
6724
|
}
|
|
5859
|
-
|
|
5860
|
-
// Send audio connected event for platform parity
|
|
5861
|
-
// with iOS
|
|
5862
|
-
Bridge.sendAudioConnected(device.name)
|
|
5863
6725
|
}
|
|
5864
6726
|
BluetoothDevice.BOND_NONE -> {
|
|
5865
6727
|
Bridge.log(
|
|
5866
6728
|
"LIVE: CTKD: ❌ Bonding failed or removed for device"
|
|
5867
6729
|
)
|
|
6730
|
+
markPairingTiming(
|
|
6731
|
+
"ctkd_bond_none",
|
|
6732
|
+
"prev=$previousBondState queueSize=${sendQueue.size}"
|
|
6733
|
+
)
|
|
5868
6734
|
isBtClassicConnected = false
|
|
5869
6735
|
audioConnected = false
|
|
5870
6736
|
if (previousBondState == BluetoothDevice.BOND_BONDING) {
|
|
@@ -5925,10 +6791,12 @@ class MentraLive : SGCManager() {
|
|
|
5925
6791
|
Bridge.sendAudioDisconnected()
|
|
5926
6792
|
}
|
|
5927
6793
|
}
|
|
5928
|
-
BluetoothDevice.BOND_BONDING ->
|
|
6794
|
+
BluetoothDevice.BOND_BONDING -> {
|
|
5929
6795
|
Bridge.log(
|
|
5930
6796
|
"LIVE: CTKD: 🔄 Bonding in progress with device"
|
|
5931
6797
|
)
|
|
6798
|
+
markPairingTiming("ctkd_bonding")
|
|
6799
|
+
}
|
|
5932
6800
|
else ->
|
|
5933
6801
|
Bridge.log(
|
|
5934
6802
|
"LIVE: CTKD: Unknown bond state: " + bondState
|
|
@@ -5963,6 +6831,48 @@ class MentraLive : SGCManager() {
|
|
|
5963
6831
|
}
|
|
5964
6832
|
}
|
|
5965
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
|
+
|
|
5966
6876
|
/**
|
|
5967
6877
|
* Create bond with device for CTKD (Cross-Transport Key Derivation) This will establish both
|
|
5968
6878
|
* BLE and BT Classic connections automatically
|
|
@@ -6069,6 +6979,7 @@ class MentraLive : SGCManager() {
|
|
|
6069
6979
|
|
|
6070
6980
|
if (state == BluetoothProfile.STATE_CONNECTED) {
|
|
6071
6981
|
Bridge.log("LIVE: A2DP: Already connected to " + device.name)
|
|
6982
|
+
a2dpConnectAttempts = 0
|
|
6072
6983
|
markAudioConnected(device.name)
|
|
6073
6984
|
} else if (state == BluetoothProfile.STATE_DISCONNECTED) {
|
|
6074
6985
|
Bridge.log("LIVE: A2DP: Connecting to " + device.name)
|
|
@@ -6077,37 +6988,50 @@ class MentraLive : SGCManager() {
|
|
|
6077
6988
|
BluetoothA2dp::class.java.getMethod("connect", BluetoothDevice::class.java)
|
|
6078
6989
|
val result = connectMethod.invoke(a2dpProfile, device) as Boolean
|
|
6079
6990
|
Bridge.log("LIVE: A2DP: Connect initiated, result: " + result)
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
markAudioConnected(device.name)
|
|
6991
|
+
if (result) {
|
|
6992
|
+
scheduleA2dpConnectRetry(device, "waiting for STATE_CONNECTED")
|
|
6993
|
+
} else {
|
|
6994
|
+
scheduleA2dpConnectRetry(device, "connect() returned false")
|
|
6995
|
+
}
|
|
6086
6996
|
} else if (state == BluetoothProfile.STATE_CONNECTING) {
|
|
6087
|
-
Bridge.log("LIVE: A2DP: Already connecting
|
|
6088
|
-
|
|
6997
|
+
Bridge.log("LIVE: A2DP: Already connecting — waiting for STATE_CONNECTED")
|
|
6998
|
+
scheduleA2dpConnectRetry(device, "still connecting")
|
|
6089
6999
|
} else {
|
|
6090
|
-
|
|
6091
|
-
Bridge.log("LIVE: A2DP: Device disconnecting, will retry in 500ms")
|
|
6092
|
-
handler.postDelayed(
|
|
6093
|
-
Runnable {
|
|
6094
|
-
if (isKilled) {
|
|
6095
|
-
return@Runnable
|
|
6096
|
-
}
|
|
6097
|
-
if (connectedDevice != null && a2dpProfile != null) {
|
|
6098
|
-
connectA2dpWithProxy(connectedDevice)
|
|
6099
|
-
}
|
|
6100
|
-
},
|
|
6101
|
-
500
|
|
6102
|
-
)
|
|
7000
|
+
scheduleA2dpConnectRetry(device, "device disconnecting")
|
|
6103
7001
|
}
|
|
6104
7002
|
} catch (e: Exception) {
|
|
6105
7003
|
Bridge.log("LIVE: A2DP: Error connecting: " + e.message)
|
|
6106
|
-
|
|
6107
|
-
markAudioConnected(device.name)
|
|
7004
|
+
scheduleA2dpConnectRetry(device, "exception: ${e.message}")
|
|
6108
7005
|
}
|
|
6109
7006
|
}
|
|
6110
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
|
|
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
|
+
)
|
|
7033
|
+
}
|
|
7034
|
+
|
|
6111
7035
|
/** Helper to mark audio as connected and notify */
|
|
6112
7036
|
private fun markAudioConnected(deviceName: String?) {
|
|
6113
7037
|
if (isKilled) {
|
|
@@ -6123,8 +7047,14 @@ class MentraLive : SGCManager() {
|
|
|
6123
7047
|
Bridge.log(
|
|
6124
7048
|
"LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
|
|
6125
7049
|
)
|
|
7050
|
+
endPairingTiming(
|
|
7051
|
+
"fully_connected",
|
|
7052
|
+
"via=a2dp+glasses_ready queueSize=${sendQueue.size}"
|
|
7053
|
+
)
|
|
6126
7054
|
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
6127
7055
|
updateConnectionState(ConnTypes.CONNECTED)
|
|
7056
|
+
} else {
|
|
7057
|
+
markPairingTiming("a2dp_connected_waiting_glasses_ready")
|
|
6128
7058
|
}
|
|
6129
7059
|
}
|
|
6130
7060
|
|
|
@@ -6148,6 +7078,7 @@ class MentraLive : SGCManager() {
|
|
|
6148
7078
|
}
|
|
6149
7079
|
|
|
6150
7080
|
Bridge.log("LIVE: A2DP: Requesting A2DP profile proxy for " + device.name)
|
|
7081
|
+
a2dpConnectAttempts = 0
|
|
6151
7082
|
|
|
6152
7083
|
// If we already have the proxy, try to connect directly
|
|
6153
7084
|
if (a2dpProfile != null) {
|
|
@@ -6168,16 +7099,133 @@ class MentraLive : SGCManager() {
|
|
|
6168
7099
|
Bridge.log("LIVE: A2DP: Profile proxy request successful, waiting for callback")
|
|
6169
7100
|
} else {
|
|
6170
7101
|
Bridge.log(
|
|
6171
|
-
"LIVE: A2DP: Failed to get profile proxy
|
|
7102
|
+
"LIVE: A2DP: Failed to get profile proxy — Classic audio not connected"
|
|
6172
7103
|
)
|
|
6173
|
-
// Still mark as connected - device is bonded and BLE audio (LC3) will work
|
|
6174
|
-
markAudioConnected(device.name)
|
|
6175
7104
|
}
|
|
6176
7105
|
} else {
|
|
6177
7106
|
Bridge.log("LIVE: A2DP: Proxy already registered, waiting for callback")
|
|
6178
7107
|
}
|
|
6179
7108
|
}
|
|
6180
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
|
+
|
|
6181
7229
|
/** Close the A2DP profile proxy */
|
|
6182
7230
|
private fun closeA2dpProxy() {
|
|
6183
7231
|
if (a2dpProfile != null && bluetoothAdapter != null) {
|
|
@@ -6193,6 +7241,14 @@ class MentraLive : SGCManager() {
|
|
|
6193
7241
|
|
|
6194
7242
|
// Mark as killed to prevent reconnection attempts
|
|
6195
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
|
|
6196
7252
|
|
|
6197
7253
|
// Stop scanning if in progress
|
|
6198
7254
|
if (isScanning) {
|
|
@@ -6203,7 +7259,10 @@ class MentraLive : SGCManager() {
|
|
|
6203
7259
|
// CTKD Implementation: Unregister bonding receiver
|
|
6204
7260
|
unregisterBondingReceiver()
|
|
6205
7261
|
|
|
6206
|
-
//
|
|
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)
|
|
6207
7266
|
closeA2dpProxy()
|
|
6208
7267
|
|
|
6209
7268
|
// Stop readiness check loop
|
|
@@ -6274,6 +7333,9 @@ class MentraLive : SGCManager() {
|
|
|
6274
7333
|
reconnectAttempts = 0
|
|
6275
7334
|
isReconnecting = false
|
|
6276
7335
|
glassesReady = false
|
|
7336
|
+
glassesReadyReceived = false
|
|
7337
|
+
audioConnected = false
|
|
7338
|
+
ctkdInitiatedThisGattSession = false
|
|
6277
7339
|
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
6278
7340
|
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
6279
7341
|
|
|
@@ -7293,6 +8355,7 @@ class MentraLive : SGCManager() {
|
|
|
7293
8355
|
/** Sends phone_ready to (re)start the glasses-driven readiness flow. */
|
|
7294
8356
|
private fun sendPhoneReady(reason: String) {
|
|
7295
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}")
|
|
7296
8359
|
val readyMsg = JSONObject()
|
|
7297
8360
|
readyMsg.put("type", "phone_ready")
|
|
7298
8361
|
readyMsg.put("timestamp", System.currentTimeMillis())
|
|
@@ -7416,6 +8479,10 @@ class MentraLive : SGCManager() {
|
|
|
7416
8479
|
payload
|
|
7417
8480
|
)
|
|
7418
8481
|
Bridge.log("LIVE: Sending BLE wire v2 handshake")
|
|
8482
|
+
markPairingTiming(
|
|
8483
|
+
"wire_handshake_sent",
|
|
8484
|
+
"attempt=${wireHandshakeAttempts + 1} queueSize=${sendQueue.size}"
|
|
8485
|
+
)
|
|
7419
8486
|
wireHandshakeQueued = true
|
|
7420
8487
|
wireHandshakeSentGeneration = wireSessionGeneration
|
|
7421
8488
|
queueData(packed, null)
|
|
@@ -7442,6 +8509,10 @@ class MentraLive : SGCManager() {
|
|
|
7442
8509
|
WIRE_HANDSHAKE_MAX_ATTEMPTS +
|
|
7443
8510
|
") - re-arming"
|
|
7444
8511
|
)
|
|
8512
|
+
markPairingTiming(
|
|
8513
|
+
"wire_handshake_unanswered",
|
|
8514
|
+
"attempt=$wireHandshakeAttempts queueSize=${sendQueue.size}"
|
|
8515
|
+
)
|
|
7445
8516
|
wireHandshakeQueued = false
|
|
7446
8517
|
if (wireHandshakeAttempts < WIRE_HANDSHAKE_MAX_ATTEMPTS) {
|
|
7447
8518
|
maybeSendWireHandshake()
|
|
@@ -7470,6 +8541,7 @@ class MentraLive : SGCManager() {
|
|
|
7470
8541
|
Bridge.log("LIVE: Ignoring wire v2 handshake reply from a previous session epoch")
|
|
7471
8542
|
return
|
|
7472
8543
|
}
|
|
8544
|
+
markPairingTiming("wire_handshake_confirmed", "queueSize=${sendQueue.size}")
|
|
7473
8545
|
activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
|
|
7474
8546
|
}
|
|
7475
8547
|
|
|
@@ -7570,10 +8642,22 @@ class MentraLive : SGCManager() {
|
|
|
7570
8642
|
* @param data The string data to be sent to the glasses
|
|
7571
8643
|
*/
|
|
7572
8644
|
fun sendDataToGlasses(data: String?, wakeup: Boolean) {
|
|
8645
|
+
sendDataToGlasses(data, wakeup, bleSessionGeneration.get())
|
|
8646
|
+
}
|
|
8647
|
+
|
|
8648
|
+
private fun sendDataToGlasses(
|
|
8649
|
+
data: String?,
|
|
8650
|
+
wakeup: Boolean,
|
|
8651
|
+
sessionGeneration: Long
|
|
8652
|
+
) {
|
|
7573
8653
|
if (data == null || data.isEmpty()) {
|
|
7574
8654
|
Log.e(TAG, "Cannot send empty data to glasses")
|
|
7575
8655
|
return
|
|
7576
8656
|
}
|
|
8657
|
+
if (sessionGeneration != bleSessionGeneration.get()) {
|
|
8658
|
+
Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
|
|
8659
|
+
return
|
|
8660
|
+
}
|
|
7577
8661
|
|
|
7578
8662
|
val wireData =
|
|
7579
8663
|
try {
|
|
@@ -7600,7 +8684,13 @@ class MentraLive : SGCManager() {
|
|
|
7600
8684
|
}
|
|
7601
8685
|
|
|
7602
8686
|
if (useBinaryWireProtocol && buildNumberInt >= 5) {
|
|
7603
|
-
sendDataToGlassesBinary(
|
|
8687
|
+
sendDataToGlassesBinary(
|
|
8688
|
+
wireData,
|
|
8689
|
+
wakeup,
|
|
8690
|
+
commandTraceInfo,
|
|
8691
|
+
isPhotoRequest,
|
|
8692
|
+
sessionGeneration
|
|
8693
|
+
)
|
|
7604
8694
|
return
|
|
7605
8695
|
}
|
|
7606
8696
|
|
|
@@ -7679,7 +8769,7 @@ class MentraLive : SGCManager() {
|
|
|
7679
8769
|
)
|
|
7680
8770
|
|
|
7681
8771
|
// Queue the chunk for sending
|
|
7682
|
-
queueData(packedData, trace)
|
|
8772
|
+
queueData(packedData, trace, sessionGeneration)
|
|
7683
8773
|
|
|
7684
8774
|
// Add small delay between chunks to avoid overwhelming the connection
|
|
7685
8775
|
if (i < chunks.size - 1) {
|
|
@@ -7720,7 +8810,7 @@ class MentraLive : SGCManager() {
|
|
|
7720
8810
|
)
|
|
7721
8811
|
|
|
7722
8812
|
// Queue the data for sending
|
|
7723
|
-
queueData(packedData, trace)
|
|
8813
|
+
queueData(packedData, trace, sessionGeneration)
|
|
7724
8814
|
if (isPhotoRequest) {
|
|
7725
8815
|
Bridge.log(
|
|
7726
8816
|
"LIVE: PHOTO PIPELINE BLE handoff — packedLen=" +
|
|
@@ -7738,7 +8828,8 @@ class MentraLive : SGCManager() {
|
|
|
7738
8828
|
data: String,
|
|
7739
8829
|
wakeup: Boolean,
|
|
7740
8830
|
commandTraceInfo: OutgoingBleCommandTraceInfo,
|
|
7741
|
-
isPhotoRequest: Boolean
|
|
8831
|
+
isPhotoRequest: Boolean,
|
|
8832
|
+
sessionGeneration: Long
|
|
7742
8833
|
) {
|
|
7743
8834
|
val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
|
|
7744
8835
|
var messageId = 0
|
|
@@ -7790,7 +8881,7 @@ class MentraLive : SGCManager() {
|
|
|
7790
8881
|
"wireProtocol" to "v2"
|
|
7791
8882
|
)
|
|
7792
8883
|
)
|
|
7793
|
-
queueData(packedData, trace)
|
|
8884
|
+
queueData(packedData, trace, sessionGeneration)
|
|
7794
8885
|
|
|
7795
8886
|
if (i < fragments.size - 1) {
|
|
7796
8887
|
try {
|
|
@@ -8124,6 +9215,19 @@ class MentraLive : SGCManager() {
|
|
|
8124
9215
|
}
|
|
8125
9216
|
}
|
|
8126
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
|
+
|
|
8127
9231
|
override fun sendSetSystemTime(timestampMs: Long) {
|
|
8128
9232
|
Bridge.log("LIVE: ⏰ Sending set_system_time to glasses: " + timestampMs)
|
|
8129
9233
|
try {
|
|
@@ -9311,6 +10415,21 @@ class MentraLive : SGCManager() {
|
|
|
9311
10415
|
stopVideoRecording(requestId, null, null)
|
|
9312
10416
|
}
|
|
9313
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
|
+
|
|
9314
10433
|
override fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
9315
10434
|
Bridge.log(
|
|
9316
10435
|
"LIVE: Stopping video recording: requestId=" +
|
|
@@ -9361,20 +10480,6 @@ class MentraLive : SGCManager() {
|
|
|
9361
10480
|
val sequenceNumber = data[1]
|
|
9362
10481
|
val receiveTime = System.currentTimeMillis()
|
|
9363
10482
|
|
|
9364
|
-
// Basic sequence validation
|
|
9365
|
-
if (lastReceivedLc3Sequence != (-1).toByte() &&
|
|
9366
|
-
(lastReceivedLc3Sequence + 1).toByte() != sequenceNumber
|
|
9367
|
-
) {
|
|
9368
|
-
Log.w(
|
|
9369
|
-
TAG,
|
|
9370
|
-
"LC3 packet sequence mismatch. Expected: " +
|
|
9371
|
-
(lastReceivedLc3Sequence + 1) +
|
|
9372
|
-
", Got: " +
|
|
9373
|
-
sequenceNumber
|
|
9374
|
-
)
|
|
9375
|
-
}
|
|
9376
|
-
lastReceivedLc3Sequence = sequenceNumber
|
|
9377
|
-
|
|
9378
10483
|
val lc3Data = Arrays.copyOfRange(data, 2, data.size)
|
|
9379
10484
|
|
|
9380
10485
|
// Enhanced LC3 packet logging and saving
|
|
@@ -9386,7 +10491,11 @@ class MentraLive : SGCManager() {
|
|
|
9386
10491
|
|
|
9387
10492
|
// Forward raw LC3 to DeviceManager (matches iOS behavior)
|
|
9388
10493
|
// MentraLive uses 40-byte LC3 frames
|
|
9389
|
-
DeviceManager.getInstance().handleGlassesMicData(
|
|
10494
|
+
DeviceManager.getInstance().handleGlassesMicData(
|
|
10495
|
+
lc3Data,
|
|
10496
|
+
LC3_FRAME_SIZE,
|
|
10497
|
+
sequenceNumber.toInt() and 0xff,
|
|
10498
|
+
)
|
|
9390
10499
|
|
|
9391
10500
|
// Bridge.log("LIVE: 🔊 Audio playback enabled: " + audioPlaybackEnabled);
|
|
9392
10501
|
// } else {
|