@mentra/bluetooth-sdk 3.2.0-dev.200 → 3.2.0-dev.203
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 +46 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +285 -29
- package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +312 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
- package/build/BluetoothSdk.types.d.ts +62 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +3 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +63 -0
- package/ios/Source/DeviceManager.swift +6 -3
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +365 -93
- package/ios/Source/WifiRequestResolution.swift +251 -0
- package/ios/Source/sgcs/MentraLive.swift +170 -35
- package/ios/Source/sgcs/SGCManager.swift +13 -1
- package/ios/Source/status/DeviceStatus.swift +28 -0
- package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +76 -1
- package/src/_private/BluetoothSdkModule.ts +4 -1
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +11 -0
|
@@ -39,6 +39,8 @@ import com.mentra.bluetoothsdk.PhotoSize
|
|
|
39
39
|
import com.mentra.bluetoothsdk.PhotoMode
|
|
40
40
|
import com.mentra.bluetoothsdk.DeviceStore
|
|
41
41
|
import com.mentra.bluetoothsdk.ObservableStore
|
|
42
|
+
import com.mentra.bluetoothsdk.incomingGlassesMessageAckId
|
|
43
|
+
import com.mentra.bluetoothsdk.wifiResponseEnvelopeIsValid
|
|
42
44
|
import com.mentra.bluetoothsdk.debug.BleTraceLogger
|
|
43
45
|
import com.mentra.bluetoothsdk.utils.BlePhotoUploadService
|
|
44
46
|
import com.mentra.bluetoothsdk.utils.ConnTypes
|
|
@@ -600,9 +602,14 @@ class MentraLive : SGCManager() {
|
|
|
600
602
|
|
|
601
603
|
// CTKD (Cross-Transport Key Derivation) support for BES devices
|
|
602
604
|
private var isBondingReceiverRegistered = false
|
|
603
|
-
private var isBtClassicConnected = false
|
|
604
605
|
private var bondingReceiver: BroadcastReceiver? = null
|
|
605
606
|
private var bondingRetryCount = 0
|
|
607
|
+
private var classicAudioReceiver: BroadcastReceiver? = null
|
|
608
|
+
private var isClassicAudioReceiverRegistered = false
|
|
609
|
+
private val classicAudioConnectionTracker =
|
|
610
|
+
ClassicAudioConnectionTracker { connected ->
|
|
611
|
+
DeviceStore.apply("glasses", "bluetoothClassicConnected", connected)
|
|
612
|
+
}
|
|
606
613
|
|
|
607
614
|
// Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
|
|
608
615
|
private var pairingTimingActive = false
|
|
@@ -1004,6 +1011,8 @@ class MentraLive : SGCManager() {
|
|
|
1004
1011
|
|
|
1005
1012
|
// Initialize CTKD bonding receiver
|
|
1006
1013
|
initializeBondingReceiver()
|
|
1014
|
+
initializeClassicAudioReceiver()
|
|
1015
|
+
registerClassicAudioReceiver()
|
|
1007
1016
|
|
|
1008
1017
|
// Initialize the send queue processor
|
|
1009
1018
|
processSendQueueRunnable = Runnable {
|
|
@@ -2272,6 +2281,8 @@ class MentraLive : SGCManager() {
|
|
|
2272
2281
|
isConnecting = false
|
|
2273
2282
|
isConnected = true
|
|
2274
2283
|
connectedDevice = gatt.device
|
|
2284
|
+
classicAudioConnectionTracker.setTarget(gatt.device.address)
|
|
2285
|
+
refreshClassicAudioConnectionState(gatt.device)
|
|
2275
2286
|
emitConnectedPendingDeviceForPairingScan()
|
|
2276
2287
|
|
|
2277
2288
|
DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
|
|
@@ -3305,8 +3316,12 @@ class MentraLive : SGCManager() {
|
|
|
3305
3316
|
return messageId
|
|
3306
3317
|
}
|
|
3307
3318
|
|
|
3308
|
-
/** Send a JSON object to the glasses with message ID and ACK tracking */
|
|
3319
|
+
/** Send a JSON object to the glasses with message ID and ACK tracking. */
|
|
3309
3320
|
private fun sendJson(json: JSONObject?, wakeup: Boolean) {
|
|
3321
|
+
sendJson(json, wakeup, true)
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
private fun sendJson(json: JSONObject?, wakeup: Boolean, bridgeLogging: Boolean): Boolean {
|
|
3310
3325
|
if (json != null) {
|
|
3311
3326
|
try {
|
|
3312
3327
|
val sessionGeneration = bleSessionGeneration.get()
|
|
@@ -3325,7 +3340,7 @@ class MentraLive : SGCManager() {
|
|
|
3325
3340
|
json,
|
|
3326
3341
|
jsonStr.length
|
|
3327
3342
|
)
|
|
3328
|
-
sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
|
|
3343
|
+
return sendDataToGlasses(jsonStr, wakeup, sessionGeneration, bridgeLogging)
|
|
3329
3344
|
} else {
|
|
3330
3345
|
// Add esoteric message ID to the JSON
|
|
3331
3346
|
val messageId = generateEsotericMessageId()
|
|
@@ -3350,12 +3365,13 @@ class MentraLive : SGCManager() {
|
|
|
3350
3365
|
// Calculate dynamic timeout for chunked message
|
|
3351
3366
|
val estimatedChunks = Math.ceil(jsonStr.length / 300.0).toInt()
|
|
3352
3367
|
ackTimeout = ACK_TIMEOUT_MS + (estimatedChunks * 50L) + 2000L
|
|
3353
|
-
|
|
3368
|
+
transportLog(
|
|
3354
3369
|
"LIVE: Message will be chunked into ~" +
|
|
3355
3370
|
estimatedChunks +
|
|
3356
3371
|
" chunks, using dynamic timeout: " +
|
|
3357
3372
|
ackTimeout +
|
|
3358
|
-
"ms"
|
|
3373
|
+
"ms",
|
|
3374
|
+
bridgeLogging,
|
|
3359
3375
|
)
|
|
3360
3376
|
}
|
|
3361
3377
|
} catch (e: JSONException) {
|
|
@@ -3367,7 +3383,13 @@ class MentraLive : SGCManager() {
|
|
|
3367
3383
|
}
|
|
3368
3384
|
|
|
3369
3385
|
// Track the message for ACK with appropriate timeout
|
|
3370
|
-
trackMessageForAck(
|
|
3386
|
+
trackMessageForAck(
|
|
3387
|
+
messageId,
|
|
3388
|
+
jsonStr,
|
|
3389
|
+
ackTimeout,
|
|
3390
|
+
sessionGeneration,
|
|
3391
|
+
bridgeLogging,
|
|
3392
|
+
)
|
|
3371
3393
|
|
|
3372
3394
|
// Send the data
|
|
3373
3395
|
if ("take_photo" == json.optString("type", "")) {
|
|
@@ -3386,7 +3408,9 @@ class MentraLive : SGCManager() {
|
|
|
3386
3408
|
json,
|
|
3387
3409
|
jsonStr.length
|
|
3388
3410
|
)
|
|
3389
|
-
sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
|
|
3411
|
+
val accepted = sendDataToGlasses(jsonStr, wakeup, sessionGeneration, bridgeLogging)
|
|
3412
|
+
if (!accepted) pendingMessages.remove(messageId)
|
|
3413
|
+
return accepted
|
|
3390
3414
|
}
|
|
3391
3415
|
} catch (e: JSONException) {
|
|
3392
3416
|
Log.e(TAG, "Error adding message ID to JSON", e)
|
|
@@ -3394,6 +3418,7 @@ class MentraLive : SGCManager() {
|
|
|
3394
3418
|
} else {
|
|
3395
3419
|
Bridge.log("LIVE: Cannot send JSON to ASG, JSON is null")
|
|
3396
3420
|
}
|
|
3421
|
+
return false
|
|
3397
3422
|
}
|
|
3398
3423
|
|
|
3399
3424
|
private fun sendJson(json: JSONObject?) {
|
|
@@ -3411,20 +3436,25 @@ class MentraLive : SGCManager() {
|
|
|
3411
3436
|
messageId: Long,
|
|
3412
3437
|
messageData: String,
|
|
3413
3438
|
timeoutMs: Long,
|
|
3414
|
-
sessionGeneration: Long
|
|
3439
|
+
sessionGeneration: Long,
|
|
3440
|
+
bridgeLogging: Boolean = true,
|
|
3415
3441
|
) {
|
|
3416
3442
|
if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
|
|
3417
|
-
|
|
3443
|
+
transportLog(
|
|
3444
|
+
"LIVE: Not connected, skipping ACK tracking for message " + messageId,
|
|
3445
|
+
bridgeLogging,
|
|
3446
|
+
)
|
|
3418
3447
|
return
|
|
3419
3448
|
}
|
|
3420
3449
|
|
|
3421
3450
|
// Skip ACK tracking for glasses with build number < 5 (older firmware)
|
|
3422
3451
|
if (buildNumberInt < 5) {
|
|
3423
|
-
|
|
3452
|
+
transportLog(
|
|
3424
3453
|
"LIVE: Glasses build number (" +
|
|
3425
3454
|
buildNumberInt +
|
|
3426
3455
|
") < 5, skipping ACK tracking for message " +
|
|
3427
|
-
messageId
|
|
3456
|
+
messageId,
|
|
3457
|
+
bridgeLogging,
|
|
3428
3458
|
)
|
|
3429
3459
|
return
|
|
3430
3460
|
}
|
|
@@ -3446,11 +3476,20 @@ class MentraLive : SGCManager() {
|
|
|
3446
3476
|
// Schedule ACK timeout with custom timeout
|
|
3447
3477
|
handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
|
|
3448
3478
|
|
|
3449
|
-
|
|
3450
|
-
"LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
|
|
3479
|
+
transportLog(
|
|
3480
|
+
"LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)",
|
|
3481
|
+
bridgeLogging,
|
|
3451
3482
|
)
|
|
3452
3483
|
}
|
|
3453
3484
|
|
|
3485
|
+
private fun transportLog(message: String, bridgeLogging: Boolean) {
|
|
3486
|
+
if (bridgeLogging) {
|
|
3487
|
+
Bridge.log(message)
|
|
3488
|
+
} else {
|
|
3489
|
+
Log.d(TAG, message)
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3454
3493
|
/**
|
|
3455
3494
|
* Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
|
|
3456
3495
|
* the old generation, and queued retries retain it, so a retry racing this clear cannot become
|
|
@@ -3944,6 +3983,9 @@ class MentraLive : SGCManager() {
|
|
|
3944
3983
|
}
|
|
3945
3984
|
}
|
|
3946
3985
|
|
|
3986
|
+
val incomingMessageId = if (json.has("mId")) json.optLong("mId") else null
|
|
3987
|
+
incomingGlassesMessageAckId(type, incomingMessageId)?.let(::sendAckToGlasses)
|
|
3988
|
+
|
|
3947
3989
|
// Check if this is a K900 command format (has "C" field instead of "type")
|
|
3948
3990
|
if (json.has("C")) {
|
|
3949
3991
|
processK900JsonMessage(json)
|
|
@@ -4098,6 +4140,44 @@ class MentraLive : SGCManager() {
|
|
|
4098
4140
|
wifiError.takeIf { it.isNotEmpty() }
|
|
4099
4141
|
)
|
|
4100
4142
|
}
|
|
4143
|
+
"wifi_forget_result" -> {
|
|
4144
|
+
if (!wifiResponseEnvelopeIsValid(json.keys().asSequence().associateWith { json.get(it) }, allowLegacy = true)) return
|
|
4145
|
+
Bridge.sendWifiForgetResult(
|
|
4146
|
+
requestId = json.optString("requestId", ""),
|
|
4147
|
+
sid = json.optString("sid", ""),
|
|
4148
|
+
ssid = json.optString("ssid", ""),
|
|
4149
|
+
protocolVersion = json.optInt("protocol_version", 0),
|
|
4150
|
+
outcome = json.optString("outcome", ""),
|
|
4151
|
+
legacyDispatched =
|
|
4152
|
+
if (json.has("dispatched")) json.optBoolean("dispatched") else null,
|
|
4153
|
+
connected =
|
|
4154
|
+
if (json.has("connected")) json.optBoolean("connected") else null,
|
|
4155
|
+
currentSsid = json.optString("current_ssid", ""),
|
|
4156
|
+
localIp = json.optString("local_ip", ""),
|
|
4157
|
+
error = json.optString("error", "").ifEmpty { null },
|
|
4158
|
+
)
|
|
4159
|
+
}
|
|
4160
|
+
"saved_wifi_networks" -> {
|
|
4161
|
+
if (!wifiResponseEnvelopeIsValid(json.keys().asSequence().associateWith { json.get(it) }, allowLegacy = false)) return
|
|
4162
|
+
val networks = mutableListOf<String>()
|
|
4163
|
+
val networkArray = json.optJSONArray("networks") ?: return
|
|
4164
|
+
if (networkArray != null) {
|
|
4165
|
+
for (i in 0 until networkArray.length()) {
|
|
4166
|
+
val network = networkArray.opt(i) as? String ?: return
|
|
4167
|
+
network.takeIf { it.trim().isNotEmpty() }?.let {
|
|
4168
|
+
networks.add(it)
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
Bridge.sendSavedWifiNetworks(
|
|
4173
|
+
requestId = json.optString("requestId", ""),
|
|
4174
|
+
sid = json.optString("sid", ""),
|
|
4175
|
+
protocolVersion = json.optInt("protocol_version", 0),
|
|
4176
|
+
outcome = json.optString("outcome", ""),
|
|
4177
|
+
networks = networks,
|
|
4178
|
+
error = json.optString("error", "").ifEmpty { null },
|
|
4179
|
+
)
|
|
4180
|
+
}
|
|
4101
4181
|
"hotspot_status_update" -> {
|
|
4102
4182
|
// Process hotspot status information (same pattern as "wifi_status")
|
|
4103
4183
|
val hotspotEnabled = json.optBoolean("hotspot_enabled", false)
|
|
@@ -4479,7 +4559,11 @@ class MentraLive : SGCManager() {
|
|
|
4479
4559
|
// Record the session id BEFORE marking ready: an unsolicited glasses_ready
|
|
4480
4560
|
// already runs this full remote-reset flow, so recording (not re-triggering)
|
|
4481
4561
|
// is correct here; version_info detection covers the restart case.
|
|
4482
|
-
json.optString("sid", "").takeIf { it.isNotEmpty() }
|
|
4562
|
+
glassesSessionId = json.optString("sid", "").takeIf { it.isNotEmpty() }
|
|
4563
|
+
Bridge.sendTypedMessage(
|
|
4564
|
+
"wifi_protocol_session_ready",
|
|
4565
|
+
mapOf("sid" to (glassesSessionId ?: "")),
|
|
4566
|
+
)
|
|
4483
4567
|
readinessCompletedThisBleSession = true
|
|
4484
4568
|
|
|
4485
4569
|
// Set the ready flag to stop any future readiness checks
|
|
@@ -4673,6 +4757,7 @@ class MentraLive : SGCManager() {
|
|
|
4673
4757
|
}
|
|
4674
4758
|
|
|
4675
4759
|
val versionInfoLegacy = HashMap<String, Any>()
|
|
4760
|
+
versionInfoLegacy["version_info_type"] = "version_info"
|
|
4676
4761
|
versionInfoLegacy["appVersion"] = appVersionLegacy
|
|
4677
4762
|
versionInfoLegacy["buildNumber"] = buildNumberLegacy
|
|
4678
4763
|
versionInfoLegacy["deviceModel"] = deviceModelLegacy
|
|
@@ -4856,6 +4941,7 @@ class MentraLive : SGCManager() {
|
|
|
4856
4941
|
|
|
4857
4942
|
// Extract all fields from JSON (except "type")
|
|
4858
4943
|
val fields = HashMap<String, Any>()
|
|
4944
|
+
fields["version_info_type"] = type
|
|
4859
4945
|
val keys = json.keys()
|
|
4860
4946
|
while (keys.hasNext()) {
|
|
4861
4947
|
val key = keys.next()
|
|
@@ -6978,7 +7064,16 @@ class MentraLive : SGCManager() {
|
|
|
6978
7064
|
"ctkd_bond_none",
|
|
6979
7065
|
"prev=$previousBondState queueSize=${sendQueue.size}"
|
|
6980
7066
|
)
|
|
6981
|
-
|
|
7067
|
+
if (previousBondState == BluetoothDevice.BOND_BONDED) {
|
|
7068
|
+
// A real unpair ends this Classic session. Invalidate
|
|
7069
|
+
// the target so delayed profile broadcasts cannot
|
|
7070
|
+
// make pairing look connected again.
|
|
7071
|
+
classicAudioConnectionTracker.invalidate(device.address)
|
|
7072
|
+
} else {
|
|
7073
|
+
// A failed/cancelled bond may be retried below, so keep
|
|
7074
|
+
// the target while clearing any partial profile state.
|
|
7075
|
+
classicAudioConnectionTracker.clear(device.address)
|
|
7076
|
+
}
|
|
6982
7077
|
audioConnected = false
|
|
6983
7078
|
if (previousBondState == BluetoothDevice.BOND_BONDING) {
|
|
6984
7079
|
// User cancelled or bonding failed - retry up to
|
|
@@ -7078,6 +7173,124 @@ class MentraLive : SGCManager() {
|
|
|
7078
7173
|
}
|
|
7079
7174
|
}
|
|
7080
7175
|
|
|
7176
|
+
/** Observe the two Android Classic audio profiles that Mentra Live exposes. */
|
|
7177
|
+
private fun initializeClassicAudioReceiver() {
|
|
7178
|
+
classicAudioReceiver =
|
|
7179
|
+
object : BroadcastReceiver() {
|
|
7180
|
+
override fun onReceive(context: Context?, intent: Intent?) {
|
|
7181
|
+
if (intent?.action != BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED &&
|
|
7182
|
+
intent?.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) return
|
|
7183
|
+
val device = intent.bluetoothDeviceExtra() ?: return
|
|
7184
|
+
refreshClassicAudioConnectionState(device)
|
|
7185
|
+
}
|
|
7186
|
+
}
|
|
7187
|
+
}
|
|
7188
|
+
|
|
7189
|
+
private fun registerClassicAudioReceiver() {
|
|
7190
|
+
val ctx = context ?: return
|
|
7191
|
+
val receiver = classicAudioReceiver ?: return
|
|
7192
|
+
if (isClassicAudioReceiverRegistered) return
|
|
7193
|
+
|
|
7194
|
+
try {
|
|
7195
|
+
val filter =
|
|
7196
|
+
IntentFilter().apply {
|
|
7197
|
+
addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
|
|
7198
|
+
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
|
|
7199
|
+
}
|
|
7200
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
7201
|
+
ctx.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
|
|
7202
|
+
} else {
|
|
7203
|
+
ctx.registerReceiver(receiver, filter)
|
|
7204
|
+
}
|
|
7205
|
+
isClassicAudioReceiverRegistered = true
|
|
7206
|
+
Bridge.log("LIVE: Classic: A2DP/HFP status receiver registered")
|
|
7207
|
+
} catch (e: Exception) {
|
|
7208
|
+
Bridge.log("LIVE: Classic: failed to register status receiver: " + e.message)
|
|
7209
|
+
}
|
|
7210
|
+
}
|
|
7211
|
+
|
|
7212
|
+
private fun unregisterClassicAudioReceiver() {
|
|
7213
|
+
if (!isClassicAudioReceiverRegistered || classicAudioReceiver == null) return
|
|
7214
|
+
|
|
7215
|
+
try {
|
|
7216
|
+
context?.unregisterReceiver(classicAudioReceiver)
|
|
7217
|
+
} catch (e: Exception) {
|
|
7218
|
+
Bridge.log("LIVE: Classic: failed to unregister status receiver: " + e.message)
|
|
7219
|
+
} finally {
|
|
7220
|
+
isClassicAudioReceiverRegistered = false
|
|
7221
|
+
}
|
|
7222
|
+
}
|
|
7223
|
+
|
|
7224
|
+
/**
|
|
7225
|
+
* Broadcast payloads are hints, never current truth (even when the MAC matches). Query both
|
|
7226
|
+
* profiles and publish one combined snapshot. A newer refresh or teardown invalidates all
|
|
7227
|
+
* outstanding results, including a previous connection to the same physical glasses.
|
|
7228
|
+
*/
|
|
7229
|
+
private fun refreshClassicAudioConnectionState(device: BluetoothDevice) {
|
|
7230
|
+
if (Looper.myLooper() != handler.looper) {
|
|
7231
|
+
handler.post { refreshClassicAudioConnectionState(device) }
|
|
7232
|
+
return
|
|
7233
|
+
}
|
|
7234
|
+
if (isKilled) return
|
|
7235
|
+
val adapter = bluetoothAdapter ?: return
|
|
7236
|
+
val ctx = context ?: return
|
|
7237
|
+
val ticket = classicAudioConnectionTracker.beginSnapshot(device.address) ?: return
|
|
7238
|
+
val states = mutableMapOf<ClassicAudioProfile, Boolean>()
|
|
7239
|
+
for ((profileId, audioProfile) in listOf(
|
|
7240
|
+
BluetoothProfile.A2DP to ClassicAudioProfile.A2DP,
|
|
7241
|
+
BluetoothProfile.HEADSET to ClassicAudioProfile.HEADSET,
|
|
7242
|
+
)) {
|
|
7243
|
+
try {
|
|
7244
|
+
val requested = adapter.getProfileProxy(ctx, object : BluetoothProfile.ServiceListener {
|
|
7245
|
+
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
|
7246
|
+
// Close before dispatch: destroy() may discard queued handler work.
|
|
7247
|
+
val connectedState = try {
|
|
7248
|
+
if (profile != profileId) null
|
|
7249
|
+
else proxy.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED
|
|
7250
|
+
} catch (e: Exception) {
|
|
7251
|
+
Bridge.log("LIVE: Classic: snapshot unavailable: " + e.message)
|
|
7252
|
+
null
|
|
7253
|
+
} finally {
|
|
7254
|
+
try { adapter.closeProfileProxy(profileId, proxy) }
|
|
7255
|
+
catch (_: Exception) {}
|
|
7256
|
+
}
|
|
7257
|
+
if (connectedState == null) return
|
|
7258
|
+
handler.post {
|
|
7259
|
+
if (isKilled || profile != profileId) return@post
|
|
7260
|
+
states[audioProfile] = connectedState
|
|
7261
|
+
val connected = readyClassicAudioSnapshot(states) ?: return@post
|
|
7262
|
+
classicAudioConnectionTracker.applySnapshot(
|
|
7263
|
+
ticket, device.address, connected) {
|
|
7264
|
+
if (ClassicAudioProfile.A2DP in connected) {
|
|
7265
|
+
markAudioConnected(device)
|
|
7266
|
+
} else if (states[ClassicAudioProfile.A2DP] == false && audioConnected) {
|
|
7267
|
+
audioConnected = false
|
|
7268
|
+
Bridge.sendAudioDisconnected()
|
|
7269
|
+
}
|
|
7270
|
+
}
|
|
7271
|
+
}
|
|
7272
|
+
}
|
|
7273
|
+
override fun onServiceDisconnected(profile: Int) {}
|
|
7274
|
+
}, profileId)
|
|
7275
|
+
if (!requested) {
|
|
7276
|
+
// Unknown is not disconnected. A successful other-profile read can still
|
|
7277
|
+
// publish connected without waiting for this unavailable service.
|
|
7278
|
+
Bridge.log("LIVE: Classic: profile service unavailable: $profileId")
|
|
7279
|
+
}
|
|
7280
|
+
} catch (e: Exception) {
|
|
7281
|
+
Bridge.log("LIVE: Classic: profile query unavailable: " + e.message)
|
|
7282
|
+
}
|
|
7283
|
+
}
|
|
7284
|
+
}
|
|
7285
|
+
|
|
7286
|
+
@Suppress("DEPRECATION")
|
|
7287
|
+
private fun Intent.bluetoothDeviceExtra(): BluetoothDevice? =
|
|
7288
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
7289
|
+
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
|
|
7290
|
+
} else {
|
|
7291
|
+
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
|
7292
|
+
}
|
|
7293
|
+
|
|
7081
7294
|
/**
|
|
7082
7295
|
* Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
|
|
7083
7296
|
* onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
|
|
@@ -7154,7 +7367,7 @@ class MentraLive : SGCManager() {
|
|
|
7154
7367
|
val method = device.javaClass.getMethod("removeBond")
|
|
7155
7368
|
val result = method.invoke(device) as Boolean
|
|
7156
7369
|
Bridge.log("LIVE: CTKD: Bond removal initiated, result: " + result)
|
|
7157
|
-
|
|
7370
|
+
classicAudioConnectionTracker.invalidate(device.address)
|
|
7158
7371
|
return result
|
|
7159
7372
|
} catch (e: Exception) {
|
|
7160
7373
|
Bridge.log("LIVE: CTKD: Error removing bond: " + e.message)
|
|
@@ -7164,7 +7377,7 @@ class MentraLive : SGCManager() {
|
|
|
7164
7377
|
|
|
7165
7378
|
/** Check if BT Classic is connected via CTKD */
|
|
7166
7379
|
fun isBtClassicConnected(): Boolean {
|
|
7167
|
-
return
|
|
7380
|
+
return classicAudioConnectionTracker.connected
|
|
7168
7381
|
}
|
|
7169
7382
|
|
|
7170
7383
|
/** A2DP profile service listener for connecting to already-bonded devices */
|
|
@@ -7223,11 +7436,11 @@ class MentraLive : SGCManager() {
|
|
|
7223
7436
|
try {
|
|
7224
7437
|
val state = a2dpProfile!!.getConnectionState(device)
|
|
7225
7438
|
Bridge.log("LIVE: A2DP: Current connection state: " + state)
|
|
7439
|
+
refreshClassicAudioConnectionState(device)
|
|
7226
7440
|
|
|
7227
7441
|
if (state == BluetoothProfile.STATE_CONNECTED) {
|
|
7228
7442
|
Bridge.log("LIVE: A2DP: Already connected to " + device.name)
|
|
7229
7443
|
a2dpConnectAttempts = 0
|
|
7230
|
-
markAudioConnected(device.name)
|
|
7231
7444
|
} else if (state == BluetoothProfile.STATE_DISCONNECTED) {
|
|
7232
7445
|
Bridge.log("LIVE: A2DP: Connecting to " + device.name)
|
|
7233
7446
|
// Use reflection to call connect() as it's a hidden API
|
|
@@ -7280,16 +7493,18 @@ class MentraLive : SGCManager() {
|
|
|
7280
7493
|
}
|
|
7281
7494
|
|
|
7282
7495
|
/** Helper to mark audio as connected and notify */
|
|
7283
|
-
private fun markAudioConnected(
|
|
7496
|
+
private fun markAudioConnected(device: BluetoothDevice) {
|
|
7284
7497
|
if (isKilled) {
|
|
7285
7498
|
Bridge.log(
|
|
7286
7499
|
"LIVE: A2DP: Ignoring markAudioConnected — SGC destroyed (would confuse DeviceManager)"
|
|
7287
7500
|
)
|
|
7288
7501
|
return
|
|
7289
7502
|
}
|
|
7290
|
-
|
|
7503
|
+
val wasAudioConnected = audioConnected
|
|
7291
7504
|
audioConnected = true
|
|
7292
|
-
|
|
7505
|
+
if (!wasAudioConnected) {
|
|
7506
|
+
Bridge.sendAudioConnected(safeDeviceName(device))
|
|
7507
|
+
}
|
|
7293
7508
|
if (glassesReadyReceived) {
|
|
7294
7509
|
Bridge.log(
|
|
7295
7510
|
"LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
|
|
@@ -7372,10 +7587,10 @@ class MentraLive : SGCManager() {
|
|
|
7372
7587
|
device.address +
|
|
7373
7588
|
")"
|
|
7374
7589
|
)
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7590
|
+
// The BLE device reference may already be cleared after a GATT drop. The tracker owns the
|
|
7591
|
+
// authoritative target address, so let it decide whether this teardown belongs to the
|
|
7592
|
+
// active Classic session instead of consulting connectedDevice.
|
|
7593
|
+
if (classicAudioConnectionTracker.invalidate(device.address)) {
|
|
7379
7594
|
audioConnected = false
|
|
7380
7595
|
}
|
|
7381
7596
|
|
|
@@ -7507,12 +7722,15 @@ class MentraLive : SGCManager() {
|
|
|
7507
7722
|
|
|
7508
7723
|
// CTKD Implementation: Unregister bonding receiver
|
|
7509
7724
|
unregisterBondingReceiver()
|
|
7725
|
+
unregisterClassicAudioReceiver()
|
|
7510
7726
|
|
|
7511
7727
|
// Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
|
|
7512
7728
|
// phone's system Bluetooth UI showing Mentra Live still connected.
|
|
7513
7729
|
val classicDevice = connectedDevice
|
|
7514
7730
|
disconnectClassicProfiles(classicDevice)
|
|
7515
7731
|
closeA2dpProxy()
|
|
7732
|
+
classicAudioConnectionTracker.reset()
|
|
7733
|
+
DeviceStore.apply("glasses", "bluetoothClassicConnected", false)
|
|
7516
7734
|
|
|
7517
7735
|
// Stop readiness check loop
|
|
7518
7736
|
stopReadinessCheckLoop()
|
|
@@ -8867,9 +9085,10 @@ class MentraLive : SGCManager() {
|
|
|
8867
9085
|
wireBytes: Int,
|
|
8868
9086
|
packetCount: Int,
|
|
8869
9087
|
protocolVersion: Int,
|
|
8870
|
-
direction: String
|
|
9088
|
+
direction: String,
|
|
9089
|
+
bridgeLogging: Boolean = true,
|
|
8871
9090
|
) {
|
|
8872
|
-
|
|
9091
|
+
transportLog(
|
|
8873
9092
|
"BLE_TRACE direction=" +
|
|
8874
9093
|
direction +
|
|
8875
9094
|
" proto=v" +
|
|
@@ -8879,7 +9098,8 @@ class MentraLive : SGCManager() {
|
|
|
8879
9098
|
" wire=" +
|
|
8880
9099
|
wireBytes +
|
|
8881
9100
|
" packets=" +
|
|
8882
|
-
packetCount
|
|
9101
|
+
packetCount,
|
|
9102
|
+
bridgeLogging,
|
|
8883
9103
|
)
|
|
8884
9104
|
}
|
|
8885
9105
|
|
|
@@ -8897,15 +9117,19 @@ class MentraLive : SGCManager() {
|
|
|
8897
9117
|
private fun sendDataToGlasses(
|
|
8898
9118
|
data: String?,
|
|
8899
9119
|
wakeup: Boolean,
|
|
8900
|
-
sessionGeneration: Long
|
|
8901
|
-
|
|
9120
|
+
sessionGeneration: Long,
|
|
9121
|
+
bridgeLogging: Boolean = true,
|
|
9122
|
+
): Boolean {
|
|
8902
9123
|
if (data == null || data.isEmpty()) {
|
|
8903
9124
|
Log.e(TAG, "Cannot send empty data to glasses")
|
|
8904
|
-
return
|
|
9125
|
+
return false
|
|
8905
9126
|
}
|
|
8906
9127
|
if (sessionGeneration != bleSessionGeneration.get()) {
|
|
8907
|
-
|
|
8908
|
-
|
|
9128
|
+
transportLog(
|
|
9129
|
+
"LIVE: Dropping send request from stale BLE session $sessionGeneration",
|
|
9130
|
+
bridgeLogging,
|
|
9131
|
+
)
|
|
9132
|
+
return false
|
|
8909
9133
|
}
|
|
8910
9134
|
|
|
8911
9135
|
val wireData =
|
|
@@ -8933,14 +9157,14 @@ class MentraLive : SGCManager() {
|
|
|
8933
9157
|
}
|
|
8934
9158
|
|
|
8935
9159
|
if (useBinaryWireProtocol && buildNumberInt >= 5) {
|
|
8936
|
-
sendDataToGlassesBinary(
|
|
9160
|
+
return sendDataToGlassesBinary(
|
|
8937
9161
|
wireData,
|
|
8938
9162
|
wakeup,
|
|
8939
9163
|
commandTraceInfo,
|
|
8940
9164
|
isPhotoRequest,
|
|
8941
|
-
sessionGeneration
|
|
9165
|
+
sessionGeneration,
|
|
9166
|
+
bridgeLogging,
|
|
8942
9167
|
)
|
|
8943
|
-
return
|
|
8944
9168
|
}
|
|
8945
9169
|
|
|
8946
9170
|
// First check if the message needs chunking
|
|
@@ -8954,7 +9178,7 @@ class MentraLive : SGCManager() {
|
|
|
8954
9178
|
|
|
8955
9179
|
// Check if chunking is needed
|
|
8956
9180
|
if (MessageChunker.needsChunking(testWrappedJson)) {
|
|
8957
|
-
|
|
9181
|
+
transportLog("LIVE: Message exceeds threshold, chunking required", bridgeLogging)
|
|
8958
9182
|
if (isPhotoRequest) {
|
|
8959
9183
|
Bridge.log(
|
|
8960
9184
|
"LIVE: PHOTO PIPELINE BLE handoff — chunking enabled for request payload"
|
|
@@ -8972,7 +9196,8 @@ class MentraLive : SGCManager() {
|
|
|
8972
9196
|
|
|
8973
9197
|
// Create chunks
|
|
8974
9198
|
val chunks = MessageChunker.createChunks(wireData, messageId, wakeup)
|
|
8975
|
-
|
|
9199
|
+
if (chunks.isEmpty()) return false
|
|
9200
|
+
transportLog("LIVE: Sending " + chunks.size + " chunks", bridgeLogging)
|
|
8976
9201
|
if (isPhotoRequest) {
|
|
8977
9202
|
Bridge.log(
|
|
8978
9203
|
"LIVE: PHOTO PIPELINE BLE handoff — created " +
|
|
@@ -9030,13 +9255,13 @@ class MentraLive : SGCManager() {
|
|
|
9030
9255
|
}
|
|
9031
9256
|
}
|
|
9032
9257
|
|
|
9033
|
-
|
|
9258
|
+
transportLog("LIVE: All chunks queued for transmission", bridgeLogging)
|
|
9034
9259
|
if (isPhotoRequest) {
|
|
9035
9260
|
Bridge.log("LIVE: PHOTO PIPELINE BLE handoff — all photo chunks queued")
|
|
9036
9261
|
}
|
|
9037
9262
|
} else {
|
|
9038
9263
|
// Normal single message transmission
|
|
9039
|
-
|
|
9264
|
+
transportLog("LIVE: Sending data to glasses: " + wireData, bridgeLogging)
|
|
9040
9265
|
|
|
9041
9266
|
// Pack the data using the centralized utility with the negotiated endianness
|
|
9042
9267
|
val packedData =
|
|
@@ -9070,7 +9295,9 @@ class MentraLive : SGCManager() {
|
|
|
9070
9295
|
}
|
|
9071
9296
|
} catch (e: Exception) {
|
|
9072
9297
|
Log.e(TAG, "Error creating data JSON", e)
|
|
9298
|
+
return false
|
|
9073
9299
|
}
|
|
9300
|
+
return sessionGeneration == bleSessionGeneration.get()
|
|
9074
9301
|
}
|
|
9075
9302
|
|
|
9076
9303
|
private fun sendDataToGlassesBinary(
|
|
@@ -9078,8 +9305,9 @@ class MentraLive : SGCManager() {
|
|
|
9078
9305
|
wakeup: Boolean,
|
|
9079
9306
|
commandTraceInfo: OutgoingBleCommandTraceInfo,
|
|
9080
9307
|
isPhotoRequest: Boolean,
|
|
9081
|
-
sessionGeneration: Long
|
|
9082
|
-
|
|
9308
|
+
sessionGeneration: Long,
|
|
9309
|
+
bridgeLogging: Boolean,
|
|
9310
|
+
): Boolean {
|
|
9083
9311
|
val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
|
|
9084
9312
|
var messageId = 0
|
|
9085
9313
|
var ackRequested = false
|
|
@@ -9098,6 +9326,7 @@ class MentraLive : SGCManager() {
|
|
|
9098
9326
|
wakeup,
|
|
9099
9327
|
ackRequested
|
|
9100
9328
|
)
|
|
9329
|
+
if (fragments.isEmpty()) return false
|
|
9101
9330
|
var totalWireBytes = 0
|
|
9102
9331
|
for (i in fragments.indices) {
|
|
9103
9332
|
val fragment = fragments[i]
|
|
@@ -9146,7 +9375,8 @@ class MentraLive : SGCManager() {
|
|
|
9146
9375
|
totalWireBytes,
|
|
9147
9376
|
fragments.size,
|
|
9148
9377
|
BleWireProtocol.PROTOCOL_V2,
|
|
9149
|
-
"phone_to_glasses"
|
|
9378
|
+
"phone_to_glasses",
|
|
9379
|
+
bridgeLogging,
|
|
9150
9380
|
)
|
|
9151
9381
|
|
|
9152
9382
|
if (isPhotoRequest) {
|
|
@@ -9157,6 +9387,7 @@ class MentraLive : SGCManager() {
|
|
|
9157
9387
|
totalWireBytes
|
|
9158
9388
|
)
|
|
9159
9389
|
}
|
|
9390
|
+
return sessionGeneration == bleSessionGeneration.get()
|
|
9160
9391
|
}
|
|
9161
9392
|
|
|
9162
9393
|
private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
|
|
@@ -9438,16 +9669,45 @@ class MentraLive : SGCManager() {
|
|
|
9438
9669
|
* K900 SystemUI can properly clear the cached credentials
|
|
9439
9670
|
*/
|
|
9440
9671
|
override fun forgetWifiNetwork(ssid: String) {
|
|
9441
|
-
|
|
9672
|
+
forgetWifiNetwork(ssid, null, null)
|
|
9673
|
+
}
|
|
9674
|
+
|
|
9675
|
+
override fun forgetWifiNetwork(ssid: String, requestId: String?, sid: String?): Boolean {
|
|
9676
|
+
if (!isConnected || bluetoothGatt == null || txCharacteristic == null || ssid.isBlank()) return false
|
|
9677
|
+
if ((requestId == null) != (sid == null)) return false
|
|
9678
|
+
if (requestId != null && (requestId.isBlank() || sid.isNullOrBlank())) return false
|
|
9679
|
+
Log.d(TAG, "LIVE: 📶 Sending WiFi forget command for SSID: " + ssid)
|
|
9442
9680
|
|
|
9443
9681
|
try {
|
|
9444
9682
|
val wifiCommand = JSONObject()
|
|
9445
9683
|
wifiCommand.put("type", "forget_wifi")
|
|
9446
9684
|
wifiCommand.put("ssid", ssid)
|
|
9447
|
-
|
|
9685
|
+
if (!requestId.isNullOrEmpty()) {
|
|
9686
|
+
wifiCommand.put("requestId", requestId)
|
|
9687
|
+
}
|
|
9688
|
+
if (!sid.isNullOrEmpty()) {
|
|
9689
|
+
wifiCommand.put("sid", sid)
|
|
9690
|
+
wifiCommand.put("protocolVersion", 1)
|
|
9691
|
+
}
|
|
9692
|
+
// This dispatch runs under the WiFi lifecycle lock. Keep its transport logs
|
|
9693
|
+
// native-only so a public log listener cannot re-enter reset before it is queued.
|
|
9694
|
+
return sendJson(wifiCommand, true, bridgeLogging = false)
|
|
9448
9695
|
} catch (e: JSONException) {
|
|
9449
9696
|
Log.e(TAG, "Error creating WiFi forget JSON", e)
|
|
9450
9697
|
}
|
|
9698
|
+
return false
|
|
9699
|
+
}
|
|
9700
|
+
|
|
9701
|
+
override fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean {
|
|
9702
|
+
if (!isConnected || bluetoothGatt == null || txCharacteristic == null || requestId.isBlank() || sid.isBlank()) return false
|
|
9703
|
+
val command = JSONObject()
|
|
9704
|
+
command.put("type", "request_saved_wifi_networks")
|
|
9705
|
+
command.put("requestId", requestId)
|
|
9706
|
+
command.put("protocolVersion", 1)
|
|
9707
|
+
if (sid.isNotEmpty()) {
|
|
9708
|
+
command.put("sid", sid)
|
|
9709
|
+
}
|
|
9710
|
+
return sendJson(command, true, bridgeLogging = false)
|
|
9451
9711
|
}
|
|
9452
9712
|
|
|
9453
9713
|
override fun sendHotspotState(enabled: Boolean) {
|
|
@@ -9554,6 +9814,13 @@ class MentraLive : SGCManager() {
|
|
|
9554
9814
|
}
|
|
9555
9815
|
}
|
|
9556
9816
|
|
|
9817
|
+
private fun sendAckToGlasses(messageId: Long) {
|
|
9818
|
+
val ack = JSONObject()
|
|
9819
|
+
ack.put("type", "msg_ack")
|
|
9820
|
+
ack.put("mId", messageId)
|
|
9821
|
+
sendJsonWithoutAck(ack, false)
|
|
9822
|
+
}
|
|
9823
|
+
|
|
9557
9824
|
private fun sendJsonWithoutAck(json: JSONObject?) {
|
|
9558
9825
|
sendJsonWithoutAck(json, false)
|
|
9559
9826
|
}
|