@mentra/bluetooth-sdk 3.2.0-dev.195 → 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.
Files changed (63) hide show
  1. package/README.md +64 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. package/src/index.ts +11 -0
@@ -69,9 +69,12 @@ class MentraBluetoothSdk private constructor(
69
69
  private var pendingOtaStart: PendingResponse<OtaStartAckEvent>? = null
70
70
  private var pendingStreamStop: PendingStreamStop? = null
71
71
  private var pendingWifiScan: PendingWifiScan? = null
72
+ private var pendingSavedWifiNetworks: PendingSavedWifiNetworks? = null
72
73
  private var pendingWifiStatus: PendingWifiStatusRequest? = null
74
+ private var pendingWifiForget: PendingWifiForgetRequest? = null
73
75
  private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
74
- private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
76
+ private var pendingVersionInfo: PendingVersionInfoRequest? = null
77
+ private val wifiSessionCapabilities = WifiSessionCapabilities()
75
78
  @Volatile private var configuredOtaVersionUrl: String? = null
76
79
 
77
80
  init {
@@ -178,6 +181,12 @@ class MentraBluetoothSdk private constructor(
178
181
  var uploadSucceeded: Boolean = false,
179
182
  )
180
183
 
184
+ private class PendingVersionInfoRequest(
185
+ val pending: PendingResponse<VersionInfoResult>,
186
+ val accumulator: VersionInfoResponseAccumulator,
187
+ ) {
188
+ }
189
+
181
190
  private data class PendingWifiScan(
182
191
  val pending: PendingResponse<List<WifiScanResult>>,
183
192
  val scanId: String,
@@ -189,15 +198,28 @@ class MentraBluetoothSdk private constructor(
189
198
  )
190
199
 
191
200
  private data class PendingWifiStatusRequest(
192
- val operation: WifiStatusOperation,
193
201
  val ssid: String,
194
202
  val pending: PendingResponse<WifiStatusEvent>,
195
203
  )
196
204
 
197
- private enum class WifiStatusOperation {
198
- CONNECT,
199
- FORGET,
200
- }
205
+ private data class PendingWifiForgetRequest(
206
+ val ssid: String,
207
+ val requestId: String,
208
+ var sid: String,
209
+ val epoch: Long,
210
+ val pending: PendingResponse<WifiForgetResult>,
211
+ var mode: WifiRequestMode,
212
+ var commandSent: Boolean = false,
213
+ )
214
+
215
+ private data class PendingSavedWifiNetworks(
216
+ val requestId: String,
217
+ var sid: String,
218
+ val epoch: Long,
219
+ val pending: PendingResponse<SavedWifiNetworksResult>,
220
+ var mode: WifiRequestMode,
221
+ var commandSent: Boolean = false,
222
+ )
201
223
 
202
224
  private data class PendingHotspotStatusRequest(
203
225
  val enabled: Boolean,
@@ -233,6 +255,7 @@ class MentraBluetoothSdk private constructor(
233
255
  "$operation timed out waiting for glasses response.",
234
256
  )
235
257
  }
258
+
236
259
  }
237
260
 
238
261
  fun addListener(listener: MentraBluetoothSdkListener) {
@@ -874,13 +897,13 @@ class MentraBluetoothSdk private constructor(
874
897
  suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
875
898
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
876
899
  synchronized(oneShotLock) {
877
- if (pendingWifiStatus != null) {
900
+ if (pendingWifiStatus != null || pendingWifiForget != null) {
878
901
  throw BluetoothSdkException(
879
902
  "request_in_flight",
880
903
  "A WiFi status command is already waiting for a glasses response.",
881
904
  )
882
905
  }
883
- pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.CONNECT, ssid, pending)
906
+ pendingWifiStatus = PendingWifiStatusRequest(ssid, pending)
884
907
  }
885
908
  try {
886
909
  deviceManager.sendWifiCredentials(ssid, password)
@@ -894,24 +917,94 @@ class MentraBluetoothSdk private constructor(
894
917
  }
895
918
  }
896
919
 
897
- suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
898
- val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
920
+ suspend fun forgetWifiNetwork(ssid: String): WifiForgetResult {
921
+ if (!wifiSsidIsValid(ssid)) {
922
+ throw BluetoothSdkException("invalid_ssid", "WiFi SSID cannot be empty.")
923
+ }
924
+ val pending = PendingResponse<WifiForgetResult>("WiFi forget request")
925
+ val requestId = "forget-${UUID.randomUUID()}"
926
+ lateinit var request: PendingWifiForgetRequest
899
927
  synchronized(oneShotLock) {
900
- if (pendingWifiStatus != null) {
928
+ if (pendingWifiStatus != null || pendingWifiForget != null) {
901
929
  throw BluetoothSdkException(
902
930
  "request_in_flight",
903
931
  "A WiFi status command is already waiting for a glasses response.",
904
932
  )
905
933
  }
906
- pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.FORGET, ssid, pending)
934
+ request =
935
+ PendingWifiForgetRequest(
936
+ ssid = ssid,
937
+ requestId = requestId,
938
+ sid = wifiSessionCapabilities.sessionId,
939
+ epoch = wifiSessionCapabilities.epoch,
940
+ pending = pending,
941
+ mode = wifiSessionCapabilities.forgetMode(),
942
+ )
943
+ pendingWifiForget = request
907
944
  }
908
945
  try {
909
- deviceManager.forgetWifiNetwork(ssid)
946
+ dispatchWifiForgetIfReady(request)
910
947
  return pending.await()
948
+ } catch (error: BluetoothSdkException) {
949
+ if (error.code == "request_timeout" && synchronized(oneShotLock) { request.mode == WifiRequestMode.DISCOVERING }) {
950
+ throw BluetoothSdkException(WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE, "WiFi capability negotiation timed out.")
951
+ }
952
+ throw error
911
953
  } finally {
912
954
  synchronized(oneShotLock) {
913
- if (pendingWifiStatus?.pending === pending) {
914
- pendingWifiStatus = null
955
+ if (pendingWifiForget === request) {
956
+ pendingWifiForget = null
957
+ }
958
+ }
959
+ }
960
+ }
961
+
962
+ suspend fun getSavedWifiNetworks(): SavedWifiNetworksResult {
963
+ val requestId = "saved-${UUID.randomUUID()}"
964
+ val pending = PendingResponse<SavedWifiNetworksResult>("Saved WiFi networks request")
965
+ var request: PendingSavedWifiNetworks? = null
966
+ var unsupported: SavedWifiNetworksResult? = null
967
+ synchronized(oneShotLock) {
968
+ if (pendingSavedWifiNetworks != null) {
969
+ throw BluetoothSdkException(
970
+ "request_in_flight",
971
+ "A saved WiFi networks request is already waiting for a glasses response.",
972
+ )
973
+ }
974
+ val snapshot = wifiSessionCapabilities.savedNetworksRequestSnapshot()
975
+ if (snapshot.mode == WifiRequestMode.LEGACY || snapshot.mode == WifiRequestMode.UNSUPPORTED) {
976
+ unsupported =
977
+ SavedWifiNetworksResult(
978
+ outcome = SavedWifiNetworksOutcome.UNSUPPORTED,
979
+ networks = emptyList(),
980
+ error = "saved_wifi_networks_unsupported",
981
+ )
982
+ return@synchronized
983
+ }
984
+ request =
985
+ PendingSavedWifiNetworks(
986
+ requestId = requestId,
987
+ sid = snapshot.sessionId,
988
+ epoch = snapshot.epoch,
989
+ pending = pending,
990
+ mode = snapshot.mode,
991
+ )
992
+ pendingSavedWifiNetworks = request
993
+ }
994
+ unsupported?.let { return it }
995
+ val activeRequest = checkNotNull(request)
996
+ try {
997
+ dispatchSavedWifiNetworksIfReady(activeRequest)
998
+ return pending.await()
999
+ } catch (error: BluetoothSdkException) {
1000
+ if (error.code == "request_timeout" && synchronized(oneShotLock) { activeRequest.mode == WifiRequestMode.DISCOVERING }) {
1001
+ throw BluetoothSdkException(WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE, "WiFi capability negotiation timed out.")
1002
+ }
1003
+ throw error
1004
+ } finally {
1005
+ synchronized(oneShotLock) {
1006
+ if (pendingSavedWifiNetworks === activeRequest) {
1007
+ pendingSavedWifiNetworks = null
915
1008
  }
916
1009
  }
917
1010
  }
@@ -1195,7 +1288,13 @@ class MentraBluetoothSdk private constructor(
1195
1288
  }
1196
1289
 
1197
1290
  suspend fun requestVersionInfo(): VersionInfoResult {
1291
+ val requestId = UUID.randomUUID().toString()
1198
1292
  val pending = PendingResponse<VersionInfoResult>("version info request")
1293
+ val request =
1294
+ PendingVersionInfoRequest(
1295
+ pending = pending,
1296
+ accumulator = VersionInfoResponseAccumulator(requestId),
1297
+ )
1199
1298
  synchronized(oneShotLock) {
1200
1299
  if (pendingVersionInfo != null) {
1201
1300
  throw BluetoothSdkException(
@@ -1203,14 +1302,14 @@ class MentraBluetoothSdk private constructor(
1203
1302
  "A version info request is already waiting for a glasses response.",
1204
1303
  )
1205
1304
  }
1206
- pendingVersionInfo = pending
1305
+ pendingVersionInfo = request
1207
1306
  }
1208
1307
  try {
1209
- deviceManager.requestVersionInfo()
1308
+ deviceManager.requestVersionInfo(requestId)
1210
1309
  return pending.await()
1211
1310
  } finally {
1212
1311
  synchronized(oneShotLock) {
1213
- if (pendingVersionInfo === pending) {
1312
+ if (pendingVersionInfo === request) {
1214
1313
  pendingVersionInfo = null
1215
1314
  }
1216
1315
  }
@@ -1456,6 +1555,7 @@ class MentraBluetoothSdk private constructor(
1456
1555
 
1457
1556
  override fun close() {
1458
1557
  stopStreamKeepAliveMonitor()
1558
+ resetWifiProtocolSession("", "sdk_closed")
1459
1559
  if (activityLifecycleCallbacksRegistered) {
1460
1560
  (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
1461
1561
  activityLifecycleCallbacks,
@@ -1471,6 +1571,9 @@ class MentraBluetoothSdk private constructor(
1471
1571
  private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
1472
1572
  when (ObservableStore.normalizeCategory(category)) {
1473
1573
  "glasses" -> {
1574
+ if (changes["connected"] == false) {
1575
+ resetWifiProtocolSession("", "wifi_session_disconnected")
1576
+ }
1474
1577
  analytics.observeGlassesStatus(getRawGlassesStatus())
1475
1578
  val state = getState()
1476
1579
  dispatchToListeners {
@@ -1607,6 +1710,22 @@ class MentraBluetoothSdk private constructor(
1607
1710
  }
1608
1711
  }
1609
1712
 
1713
+ private fun handleVersionInfoForRequest(data: Map<String, Any>) {
1714
+ synchronized(oneShotLock) {
1715
+ val request = pendingVersionInfo ?: return
1716
+ when (val outcome = request.accumulator.accept(data)) {
1717
+ VersionInfoAccumulatorOutcome.Ignored -> Unit
1718
+ VersionInfoAccumulatorOutcome.Waiting -> Unit
1719
+ is VersionInfoAccumulatorOutcome.Complete -> {
1720
+ if (pendingVersionInfo === request) {
1721
+ pendingVersionInfo = null
1722
+ request.pending.resolve(outcome.result)
1723
+ }
1724
+ }
1725
+ }
1726
+ }
1727
+ }
1728
+
1610
1729
  private fun dispatchBridgeEvent(eventName: String, data: Map<String, Any>) {
1611
1730
  when (eventName) {
1612
1731
  "log" -> dispatchToListeners { it.onLog(data["message"] as? String ?: data.toString()) }
@@ -1663,6 +1782,27 @@ class MentraBluetoothSdk private constructor(
1663
1782
  handleWifiStatusForRequests(event)
1664
1783
  dispatchToListeners { it.onWifiStatusChanged(event) }
1665
1784
  }
1785
+ "wifi_forget_result" -> {
1786
+ handleWifiForgetResultForRequests(data)
1787
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1788
+ }
1789
+ "saved_wifi_networks" -> {
1790
+ handleSavedWifiNetworksForRequests(data)
1791
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1792
+ }
1793
+ "wifi_protocol_session_ready" -> {
1794
+ resetWifiProtocolSession(
1795
+ data["sid"] as? String ?: "",
1796
+ "wifi_session_restarted",
1797
+ )
1798
+ }
1799
+ "glasses_session_changed" -> {
1800
+ resetWifiProtocolSession(
1801
+ data["sid"] as? String ?: "",
1802
+ "wifi_session_changed",
1803
+ )
1804
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1805
+ }
1666
1806
  "wifi_scan_result" -> {
1667
1807
  val networks =
1668
1808
  (data["networks"] as? List<*>)
@@ -1772,10 +1912,11 @@ class MentraBluetoothSdk private constructor(
1772
1912
  dispatchToListeners { it.onSettingsAck(event) }
1773
1913
  }
1774
1914
  "version_info" -> {
1775
- val event = VersionInfoResult.fromMap(data)
1776
- synchronized(oneShotLock) {
1777
- pendingVersionInfo?.resolve(event)
1915
+ if (data["versionInfoType"] == "version_info_1" || data["versionInfoType"] == "version_info") {
1916
+ applyWifiProtocolCapabilities(data)
1778
1917
  }
1918
+ val event = VersionInfoResult.fromMap(data)
1919
+ handleVersionInfoForRequest(data)
1779
1920
  dispatchToListeners { it.onVersionInfo(event) }
1780
1921
  }
1781
1922
  "mic_pcm" -> {
@@ -2194,43 +2335,184 @@ class MentraBluetoothSdk private constructor(
2194
2335
  }
2195
2336
 
2196
2337
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
2197
- val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
2338
+ val connectRequest = synchronized(oneShotLock) { pendingWifiStatus }
2198
2339
  // A wifi_status carrying the explicit error field is the glasses' failure
2199
2340
  // verdict for the in-flight connect: reject now instead of running out the
2200
2341
  // request timeout. Only the error field counts as failure — the glasses'
2201
2342
  // connect sequence emits a debounced bare connected=false ~1-2s after
2202
2343
  // credentials while association is still in progress, and rejecting on that
2203
2344
  // would kill every connect attempt early.
2204
- if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
2345
+ if (connectRequest != null && event.error != null) {
2205
2346
  synchronized(oneShotLock) {
2206
- if (pendingWifiStatus === request) {
2347
+ if (pendingWifiStatus === connectRequest) {
2207
2348
  pendingWifiStatus = null
2208
2349
  }
2209
2350
  }
2210
- request.pending.reject(
2351
+ connectRequest.pending.reject(
2211
2352
  BluetoothSdkException(
2212
2353
  event.error,
2213
- "Glasses failed to join \"${request.ssid}\": ${event.error}",
2354
+ "Glasses failed to join \"${connectRequest.ssid}\": ${event.error}",
2214
2355
  )
2215
2356
  )
2216
2357
  return
2217
2358
  }
2218
- if (!wifiStatusMatches(event.status, request)) return
2359
+ if (connectRequest != null && wifiStatusMatchesConnect(event.status, connectRequest.ssid)) {
2360
+ synchronized(oneShotLock) {
2361
+ if (pendingWifiStatus === connectRequest) {
2362
+ pendingWifiStatus = null
2363
+ }
2364
+ }
2365
+ connectRequest.pending.resolve(event)
2366
+ return
2367
+ }
2368
+
2369
+ // Forget never settles from link state: modern requests require a correlated result,
2370
+ // while legacy requests resolve as unverified at accepted dispatch.
2371
+ }
2372
+
2373
+ private fun handleWifiForgetResultForRequests(data: Map<String, Any>) {
2374
+ val request = synchronized(oneShotLock) { pendingWifiForget } ?: return
2375
+ if (request.mode != WifiRequestMode.MODERN) return
2376
+ val version =
2377
+ synchronized(oneShotLock) {
2378
+ if (request.epoch != wifiSessionCapabilities.epoch) return
2379
+ (wifiSessionCapabilities.forgetResult as? WifiProtocolCapability.Supported)?.version
2380
+ } ?: return
2381
+ val result =
2382
+ parseWifiForgetResult(
2383
+ request.requestId,
2384
+ request.sid,
2385
+ request.ssid,
2386
+ version,
2387
+ data,
2388
+ ) ?: return
2389
+
2219
2390
  synchronized(oneShotLock) {
2220
- if (pendingWifiStatus === request) {
2221
- pendingWifiStatus = null
2391
+ if (pendingWifiForget !== request || request.epoch != wifiSessionCapabilities.epoch) {
2392
+ return
2222
2393
  }
2394
+ pendingWifiForget = null
2223
2395
  }
2224
- request.pending.resolve(event)
2396
+ request.pending.resolve(result)
2225
2397
  }
2226
2398
 
2227
- private fun wifiStatusMatches(status: WifiStatus, request: PendingWifiStatusRequest): Boolean =
2228
- when (request.operation) {
2229
- WifiStatusOperation.CONNECT ->
2230
- status is WifiStatus.Connected && status.ssid == request.ssid
2231
- WifiStatusOperation.FORGET ->
2232
- status == WifiStatus.Disconnected || (status is WifiStatus.Connected && status.ssid != request.ssid)
2399
+ private fun handleSavedWifiNetworksForRequests(data: Map<String, Any>) {
2400
+ val request = synchronized(oneShotLock) { pendingSavedWifiNetworks } ?: return
2401
+ if (request.mode != WifiRequestMode.MODERN) return
2402
+ val version =
2403
+ synchronized(oneShotLock) {
2404
+ if (request.epoch != wifiSessionCapabilities.epoch) return
2405
+ (wifiSessionCapabilities.savedNetworks as? WifiProtocolCapability.Supported)?.version
2406
+ } ?: return
2407
+ val result =
2408
+ parseSavedWifiNetworks(
2409
+ request.requestId,
2410
+ request.sid,
2411
+ version,
2412
+ data,
2413
+ ) ?: return
2414
+ synchronized(oneShotLock) {
2415
+ if (pendingSavedWifiNetworks !== request || request.epoch != wifiSessionCapabilities.epoch) {
2416
+ return
2417
+ }
2418
+ pendingSavedWifiNetworks = null
2233
2419
  }
2420
+ request.pending.resolve(result)
2421
+ }
2422
+
2423
+ private fun wifiStatusMatchesConnect(status: WifiStatus, ssid: String): Boolean =
2424
+ status is WifiStatus.Connected && status.ssid == ssid
2425
+
2426
+ private fun dispatchWifiForgetIfReady(request: PendingWifiForgetRequest) {
2427
+ synchronized(oneShotLock) {
2428
+ if (pendingWifiForget !== request || request.epoch != wifiSessionCapabilities.epoch ||
2429
+ request.mode == WifiRequestMode.DISCOVERING || request.commandSent) return
2430
+ if (request.mode == WifiRequestMode.UNSUPPORTED) {
2431
+ request.pending.reject(BluetoothSdkException("wifi_protocol_unsupported", "Unsupported WiFi forget protocol version."))
2432
+ return
2433
+ }
2434
+ request.sid = wifiSessionCapabilities.sessionId
2435
+ request.commandSent = deviceManager.forgetWifiNetwork(
2436
+ request.ssid,
2437
+ request.requestId.takeIf { request.mode == WifiRequestMode.MODERN },
2438
+ request.sid.takeIf { request.mode == WifiRequestMode.MODERN },
2439
+ )
2440
+ if (!request.commandSent) {
2441
+ request.pending.reject(BluetoothSdkException("dispatch_failed", "No active glasses transport accepted WiFi forget."))
2442
+ } else if (request.mode == WifiRequestMode.LEGACY) {
2443
+ pendingWifiForget = null
2444
+ request.pending.resolve(legacyWifiForgetResult(request.ssid))
2445
+ }
2446
+ }
2447
+ }
2448
+
2449
+ private fun dispatchSavedWifiNetworksIfReady(request: PendingSavedWifiNetworks) {
2450
+ synchronized(oneShotLock) {
2451
+ if (pendingSavedWifiNetworks !== request || request.epoch != wifiSessionCapabilities.epoch ||
2452
+ request.mode != WifiRequestMode.MODERN || request.commandSent) return
2453
+ request.sid = wifiSessionCapabilities.sessionId
2454
+ request.commandSent = deviceManager.requestSavedWifiNetworks(request.requestId, request.sid)
2455
+ if (!request.commandSent) {
2456
+ request.pending.reject(BluetoothSdkException("dispatch_failed", "No active glasses transport accepted saved WiFi request."))
2457
+ }
2458
+ }
2459
+ }
2460
+
2461
+ private fun applyWifiProtocolCapabilities(data: Map<String, Any>) {
2462
+ var forgetToDispatch: PendingWifiForgetRequest? = null
2463
+ var savedToDispatch: PendingSavedWifiNetworks? = null
2464
+ var unsupportedSaved: PendingSavedWifiNetworks? = null
2465
+ synchronized(oneShotLock) {
2466
+ wifiSessionCapabilities.applyVersionInfo1(data)
2467
+ pendingWifiForget?.takeIf { it.epoch == wifiSessionCapabilities.epoch }?.let { request ->
2468
+ if (request.mode == WifiRequestMode.DISCOVERING) {
2469
+ request.mode = wifiSessionCapabilities.forgetMode()
2470
+ forgetToDispatch = request
2471
+ }
2472
+ }
2473
+ pendingSavedWifiNetworks?.takeIf { it.epoch == wifiSessionCapabilities.epoch }?.let { request ->
2474
+ if (request.mode == WifiRequestMode.DISCOVERING) {
2475
+ request.mode = wifiSessionCapabilities.savedNetworksMode()
2476
+ if (request.mode == WifiRequestMode.LEGACY || request.mode == WifiRequestMode.UNSUPPORTED) {
2477
+ pendingSavedWifiNetworks = null
2478
+ unsupportedSaved = request
2479
+ } else {
2480
+ savedToDispatch = request
2481
+ }
2482
+ }
2483
+ }
2484
+ }
2485
+ unsupportedSaved?.let { request ->
2486
+ request.pending.resolve(
2487
+ SavedWifiNetworksResult(
2488
+ outcome = SavedWifiNetworksOutcome.UNSUPPORTED,
2489
+ networks = emptyList(),
2490
+ error = "saved_wifi_networks_unsupported",
2491
+ )
2492
+ )
2493
+ }
2494
+ forgetToDispatch?.let(::dispatchWifiForgetIfReady)
2495
+ savedToDispatch?.let(::dispatchSavedWifiNetworksIfReady)
2496
+ }
2497
+
2498
+ private fun resetWifiProtocolSession(sessionId: String, code: String) {
2499
+ val error = BluetoothSdkException(code, "The glasses WiFi protocol session changed.")
2500
+ val pendingToReject = mutableListOf<PendingResponse<*>>()
2501
+ synchronized(oneShotLock) {
2502
+ wifiSessionCapabilities.reset(sessionId)
2503
+ pendingWifiStatus?.pending?.let(pendingToReject::add)
2504
+ pendingWifiForget?.pending?.let(pendingToReject::add)
2505
+ pendingSavedWifiNetworks?.pending?.let(pendingToReject::add)
2506
+ pendingWifiScan?.pending?.let(pendingToReject::add)
2507
+ pendingHotspotStatus?.pending?.let(pendingToReject::add)
2508
+ pendingWifiStatus = null
2509
+ pendingWifiForget = null
2510
+ pendingSavedWifiNetworks = null
2511
+ pendingWifiScan = null
2512
+ pendingHotspotStatus = null
2513
+ }
2514
+ pendingToReject.forEach { it.reject(error) }
2515
+ }
2234
2516
 
2235
2517
  private fun handleHotspotStatusForRequests(event: HotspotStatusEvent) {
2236
2518
  val request = synchronized(oneShotLock) { pendingHotspotStatus } ?: return
@@ -0,0 +1,4 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal fun incomingGlassesMessageAckId(type: String, messageId: Long?): Long? =
4
+ messageId?.takeIf { type != "msg_ack" }
@@ -0,0 +1,88 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal sealed interface VersionInfoAccumulatorOutcome {
4
+ data object Ignored : VersionInfoAccumulatorOutcome
5
+ data object Waiting : VersionInfoAccumulatorOutcome
6
+ data class Complete(val result: VersionInfoResult) : VersionInfoAccumulatorOutcome
7
+ }
8
+
9
+ /** One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary. */
10
+ internal class VersionInfoResponseAccumulator(private val expectedRequestId: String) {
11
+ private val values = mutableMapOf<String, Any>()
12
+ private val chunks = mutableMapOf<Int, Map<String, Any>>()
13
+ private var count: Int? = null
14
+ private var sid: String? = null
15
+ private var legacyStarted = false
16
+ private var completed = false
17
+
18
+ fun accept(event: Map<String, Any>): VersionInfoAccumulatorOutcome {
19
+ if (completed) return VersionInfoAccumulatorOutcome.Ignored
20
+ val hasModernMetadata = event.keys.any { it in MODERN_KEYS }
21
+ if (hasModernMetadata) {
22
+ if (event[RESPONSE_REQUEST_ID_KEY] != expectedRequestId) return VersionInfoAccumulatorOutcome.Ignored
23
+ val index = integer(event[RESPONSE_INDEX_KEY]) ?: return VersionInfoAccumulatorOutcome.Ignored
24
+ val total = integer(event[RESPONSE_COUNT_KEY]) ?: return VersionInfoAccumulatorOutcome.Ignored
25
+ val final = event[RESPONSE_FINAL_KEY] as? Boolean ?: return VersionInfoAccumulatorOutcome.Ignored
26
+ val process = (event[RESPONSE_SID_KEY] as? String)?.takeIf { it.isNotBlank() }
27
+ ?: return VersionInfoAccumulatorOutcome.Ignored
28
+ if (total !in 1..16 || index !in 1..total || final != (index == total)) return VersionInfoAccumulatorOutcome.Ignored
29
+ if (count != null && (count != total || sid != process)) return VersionInfoAccumulatorOutcome.Ignored
30
+ if (count == null) {
31
+ values.clear()
32
+ count = total
33
+ sid = process
34
+ }
35
+ chunks[index] = event
36
+ if (chunks.size != total) return VersionInfoAccumulatorOutcome.Waiting
37
+ (1..total).forEach { merge(chunks.getValue(it)) }
38
+ return finish()
39
+ }
40
+ // Once a response is correlated, unsolicited legacy traffic cannot replace it.
41
+ if (count != null) return VersionInfoAccumulatorOutcome.Ignored
42
+ when (event[RESPONSE_CHUNK_KEY] as? String ?: "version_info") {
43
+ "version_info" -> { values.clear(); merge(event); return finish() }
44
+ "version_info_1" -> { values.clear(); legacyStarted = true; merge(event) }
45
+ "version_info_2" -> {
46
+ if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
47
+ merge(event)
48
+ }
49
+ "version_info_3" -> {
50
+ if (!legacyStarted) return VersionInfoAccumulatorOutcome.Ignored
51
+ merge(event)
52
+ return finish()
53
+ }
54
+ else -> return VersionInfoAccumulatorOutcome.Ignored
55
+ }
56
+ // Silence is never evidence of completion. The request's normal deadline handles loss.
57
+ return VersionInfoAccumulatorOutcome.Waiting
58
+ }
59
+
60
+ private fun finish(): VersionInfoAccumulatorOutcome.Complete {
61
+ completed = true
62
+ return VersionInfoAccumulatorOutcome.Complete(VersionInfoResult.fromMap(values))
63
+ }
64
+
65
+ private fun merge(event: Map<String, Any>) {
66
+ event.forEach { (key, value) ->
67
+ if (!key.startsWith("_response") && key != "type" && (value !is String || value.isNotEmpty())) {
68
+ values[key] = value
69
+ }
70
+ }
71
+ }
72
+
73
+ private fun integer(value: Any?): Int? {
74
+ val number = value as? Number ?: return null
75
+ val int = number.toInt()
76
+ return int.takeIf { number.toDouble() == it.toDouble() }
77
+ }
78
+
79
+ companion object {
80
+ internal const val RESPONSE_CHUNK_KEY = "_responseChunk"
81
+ internal const val RESPONSE_REQUEST_ID_KEY = "_responseRequestId"
82
+ internal const val RESPONSE_INDEX_KEY = "_responseChunkIndex"
83
+ internal const val RESPONSE_COUNT_KEY = "_responseChunkCount"
84
+ internal const val RESPONSE_FINAL_KEY = "_responseFinal"
85
+ internal const val RESPONSE_SID_KEY = "_responseSid"
86
+ private val MODERN_KEYS = setOf(RESPONSE_REQUEST_ID_KEY, RESPONSE_INDEX_KEY, RESPONSE_COUNT_KEY, RESPONSE_FINAL_KEY)
87
+ }
88
+ }