@mentra/bluetooth-sdk 3.2.0-dev.200 → 3.2.0-dev.206

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 (60) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
  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 +298 -181
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +326 -45
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  23. package/build/BluetoothSdk.types.d.ts +68 -2
  24. package/build/BluetoothSdk.types.d.ts.map +1 -1
  25. package/build/BluetoothSdk.types.js.map +1 -1
  26. package/build/_private/BluetoothSdkModule.d.ts +3 -4
  27. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/generated/changelogCatalog.d.ts +1 -1
  30. package/build/generated/changelogCatalog.js +1 -1
  31. package/build/generated/changelogCatalog.js.map +1 -1
  32. package/build/generated/releaseMetadata.js +5 -5
  33. package/build/generated/releaseMetadata.js.map +1 -1
  34. package/build/index.d.ts +1 -1
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +3 -0
  37. package/build/index.js.map +1 -1
  38. package/ios/BluetoothSdkModule.swift +8 -13
  39. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  40. package/ios/Source/Bridge.swift +63 -0
  41. package/ios/Source/DeviceManager.swift +6 -3
  42. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  43. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  44. package/ios/Source/MentraBluetoothSDK.swift +372 -220
  45. package/ios/Source/WifiRequestResolution.swift +251 -0
  46. package/ios/Source/sgcs/MentraLive.swift +216 -70
  47. package/ios/Source/sgcs/SGCManager.swift +13 -1
  48. package/ios/Source/status/DeviceStatus.swift +28 -0
  49. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  50. package/ios/Source/streaming/StreamModels.swift +12 -0
  51. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  52. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  53. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  54. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  55. package/package.json +1 -1
  56. package/src/BluetoothSdk.types.ts +82 -2
  57. package/src/_private/BluetoothSdkModule.ts +4 -4
  58. package/src/generated/changelogCatalog.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. package/src/index.ts +11 -0
@@ -8,6 +8,7 @@ import android.content.Context
8
8
  import android.os.Bundle
9
9
  import android.os.Handler
10
10
  import android.os.Looper
11
+ import com.mentra.bluetoothsdk.streaming.StreamSessionState
11
12
  import com.mentra.bluetoothsdk.utils.ControllerTypes
12
13
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
13
14
  import java.util.Collections
@@ -52,8 +53,7 @@ class MentraBluetoothSdk private constructor(
52
53
  }
53
54
  private var activityLifecycleCallbacksRegistered = false
54
55
  private var suppressDefaultDeviceEvents = false
55
- private val streamKeepAliveLock = Any()
56
- private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
56
+ private val streamSession = StreamSessionState()
57
57
  private val pendingPhotoRequests = ConcurrentHashMap<String, PendingResponse<PhotoResponseEvent>>()
58
58
  private val pendingCameraStatusRequests = ConcurrentHashMap<String, PendingResponse<CameraStatusEvent>>()
59
59
  private val pendingVideoRecordingRequests =
@@ -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
76
  private var pendingVersionInfo: PendingVersionInfoRequest? = null
77
+ private val wifiSessionCapabilities = WifiSessionCapabilities()
75
78
  @Volatile private var configuredOtaVersionUrl: String? = null
76
79
 
77
80
  init {
@@ -107,7 +110,6 @@ class MentraBluetoothSdk private constructor(
107
110
  private const val OTA_BES_VERSION_WAIT_MS = 5_000L
108
111
  private const val OTA_MTK_VERSION_WAIT_MS = 2_000L
109
112
  private const val OTA_VERSION_POLL_MS = 100L
110
- private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
111
113
  private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
112
114
 
113
115
  // Stream states the glasses only reach after start_stream has run past
@@ -136,18 +138,6 @@ class MentraBluetoothSdk private constructor(
136
138
  ): MentraBluetoothSdk = MentraBluetoothSdk(context, config, listener)
137
139
  }
138
140
 
139
- private data class ActiveStreamKeepAlive(
140
- val streamId: String,
141
- val intervalMs: Long,
142
- var pendingAckId: String? = null,
143
- var missedAckCount: Int = 0,
144
- var nextTick: Runnable? = null,
145
- // Missed-ACK counting only begins once the stream is confirmed live/coming up, so a
146
- // slow startup (glasses can't ACK until they reach starting/streaming) can't trip a
147
- // false keep-alive timeout before the stream is ever up.
148
- var armed: Boolean = false,
149
- )
150
-
151
141
  // seq records send order (assigned and handed to the BLE queue under
152
142
  // streamStartOrderLock) so that an id-carrying status for a newer start can
153
143
  // identify which older in-flight starts it preempted.
@@ -195,15 +185,28 @@ class MentraBluetoothSdk private constructor(
195
185
  )
196
186
 
197
187
  private data class PendingWifiStatusRequest(
198
- val operation: WifiStatusOperation,
199
188
  val ssid: String,
200
189
  val pending: PendingResponse<WifiStatusEvent>,
201
190
  )
202
191
 
203
- private enum class WifiStatusOperation {
204
- CONNECT,
205
- FORGET,
206
- }
192
+ private data class PendingWifiForgetRequest(
193
+ val ssid: String,
194
+ val requestId: String,
195
+ var sid: String,
196
+ val epoch: Long,
197
+ val pending: PendingResponse<WifiForgetResult>,
198
+ var mode: WifiRequestMode,
199
+ var commandSent: Boolean = false,
200
+ )
201
+
202
+ private data class PendingSavedWifiNetworks(
203
+ val requestId: String,
204
+ var sid: String,
205
+ val epoch: Long,
206
+ val pending: PendingResponse<SavedWifiNetworksResult>,
207
+ var mode: WifiRequestMode,
208
+ var commandSent: Boolean = false,
209
+ )
207
210
 
208
211
  private data class PendingHotspotStatusRequest(
209
212
  val enabled: Boolean,
@@ -239,6 +242,7 @@ class MentraBluetoothSdk private constructor(
239
242
  "$operation timed out waiting for glasses response.",
240
243
  )
241
244
  }
245
+
242
246
  }
243
247
 
244
248
  fun addListener(listener: MentraBluetoothSdkListener) {
@@ -880,13 +884,13 @@ class MentraBluetoothSdk private constructor(
880
884
  suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
881
885
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
882
886
  synchronized(oneShotLock) {
883
- if (pendingWifiStatus != null) {
887
+ if (pendingWifiStatus != null || pendingWifiForget != null) {
884
888
  throw BluetoothSdkException(
885
889
  "request_in_flight",
886
890
  "A WiFi status command is already waiting for a glasses response.",
887
891
  )
888
892
  }
889
- pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.CONNECT, ssid, pending)
893
+ pendingWifiStatus = PendingWifiStatusRequest(ssid, pending)
890
894
  }
891
895
  try {
892
896
  deviceManager.sendWifiCredentials(ssid, password)
@@ -900,24 +904,94 @@ class MentraBluetoothSdk private constructor(
900
904
  }
901
905
  }
902
906
 
903
- suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
904
- val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
907
+ suspend fun forgetWifiNetwork(ssid: String): WifiForgetResult {
908
+ if (!wifiSsidIsValid(ssid)) {
909
+ throw BluetoothSdkException("invalid_ssid", "WiFi SSID cannot be empty.")
910
+ }
911
+ val pending = PendingResponse<WifiForgetResult>("WiFi forget request")
912
+ val requestId = "forget-${UUID.randomUUID()}"
913
+ lateinit var request: PendingWifiForgetRequest
905
914
  synchronized(oneShotLock) {
906
- if (pendingWifiStatus != null) {
915
+ if (pendingWifiStatus != null || pendingWifiForget != null) {
907
916
  throw BluetoothSdkException(
908
917
  "request_in_flight",
909
918
  "A WiFi status command is already waiting for a glasses response.",
910
919
  )
911
920
  }
912
- pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.FORGET, ssid, pending)
921
+ request =
922
+ PendingWifiForgetRequest(
923
+ ssid = ssid,
924
+ requestId = requestId,
925
+ sid = wifiSessionCapabilities.sessionId,
926
+ epoch = wifiSessionCapabilities.epoch,
927
+ pending = pending,
928
+ mode = wifiSessionCapabilities.forgetMode(),
929
+ )
930
+ pendingWifiForget = request
913
931
  }
914
932
  try {
915
- deviceManager.forgetWifiNetwork(ssid)
933
+ dispatchWifiForgetIfReady(request)
916
934
  return pending.await()
935
+ } catch (error: BluetoothSdkException) {
936
+ if (error.code == "request_timeout" && synchronized(oneShotLock) { request.mode == WifiRequestMode.DISCOVERING }) {
937
+ throw BluetoothSdkException(WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE, "WiFi capability negotiation timed out.")
938
+ }
939
+ throw error
917
940
  } finally {
918
941
  synchronized(oneShotLock) {
919
- if (pendingWifiStatus?.pending === pending) {
920
- pendingWifiStatus = null
942
+ if (pendingWifiForget === request) {
943
+ pendingWifiForget = null
944
+ }
945
+ }
946
+ }
947
+ }
948
+
949
+ suspend fun getSavedWifiNetworks(): SavedWifiNetworksResult {
950
+ val requestId = "saved-${UUID.randomUUID()}"
951
+ val pending = PendingResponse<SavedWifiNetworksResult>("Saved WiFi networks request")
952
+ var request: PendingSavedWifiNetworks? = null
953
+ var unsupported: SavedWifiNetworksResult? = null
954
+ synchronized(oneShotLock) {
955
+ if (pendingSavedWifiNetworks != null) {
956
+ throw BluetoothSdkException(
957
+ "request_in_flight",
958
+ "A saved WiFi networks request is already waiting for a glasses response.",
959
+ )
960
+ }
961
+ val snapshot = wifiSessionCapabilities.savedNetworksRequestSnapshot()
962
+ if (snapshot.mode == WifiRequestMode.LEGACY || snapshot.mode == WifiRequestMode.UNSUPPORTED) {
963
+ unsupported =
964
+ SavedWifiNetworksResult(
965
+ outcome = SavedWifiNetworksOutcome.UNSUPPORTED,
966
+ networks = emptyList(),
967
+ error = "saved_wifi_networks_unsupported",
968
+ )
969
+ return@synchronized
970
+ }
971
+ request =
972
+ PendingSavedWifiNetworks(
973
+ requestId = requestId,
974
+ sid = snapshot.sessionId,
975
+ epoch = snapshot.epoch,
976
+ pending = pending,
977
+ mode = snapshot.mode,
978
+ )
979
+ pendingSavedWifiNetworks = request
980
+ }
981
+ unsupported?.let { return it }
982
+ val activeRequest = checkNotNull(request)
983
+ try {
984
+ dispatchSavedWifiNetworksIfReady(activeRequest)
985
+ return pending.await()
986
+ } catch (error: BluetoothSdkException) {
987
+ if (error.code == "request_timeout" && synchronized(oneShotLock) { activeRequest.mode == WifiRequestMode.DISCOVERING }) {
988
+ throw BluetoothSdkException(WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE, "WiFi capability negotiation timed out.")
989
+ }
990
+ throw error
991
+ } finally {
992
+ synchronized(oneShotLock) {
993
+ if (pendingSavedWifiNetworks === activeRequest) {
994
+ pendingSavedWifiNetworks = null
921
995
  }
922
996
  }
923
997
  }
@@ -1028,16 +1102,11 @@ class MentraBluetoothSdk private constructor(
1028
1102
  }
1029
1103
  }
1030
1104
 
1031
- suspend fun startStream(request: StreamRequest): StreamStatusEvent =
1032
- startStream(request, startSdkKeepAlive = true)
1033
-
1034
- internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
1035
- startStream(request, startSdkKeepAlive = false)
1036
-
1037
- private suspend fun startStream(
1038
- request: StreamRequest,
1039
- startSdkKeepAlive: Boolean,
1040
- ): StreamStatusEvent {
1105
+ suspend fun startStream(request: StreamRequest): StreamStatusEvent {
1106
+ requireGlassesConnected("start stream")
1107
+ if (!streamSession.supported) {
1108
+ throw BluetoothSdkException("stream_control_unsupported", "Update the glasses software before starting a stream.")
1109
+ }
1041
1110
  val message = request.toMap().toMutableMap()
1042
1111
  val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
1043
1112
  ?: "sdk-${UUID.randomUUID()}"
@@ -1052,23 +1121,15 @@ class MentraBluetoothSdk private constructor(
1052
1121
  val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1053
1122
  pendingStreamStarts[streamId] = registered
1054
1123
  start = registered
1055
- stopStreamKeepAliveMonitor()
1056
1124
  deviceManager.startStream(message)
1057
1125
  }
1058
1126
  val event = pending.await(STREAM_START_TIMEOUT_MS)
1059
- if (startSdkKeepAlive) {
1060
- startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
1061
- }
1062
1127
  return event
1063
1128
  } finally {
1064
1129
  start?.let { pendingStreamStarts.remove(streamId, it) }
1065
1130
  }
1066
1131
  }
1067
1132
 
1068
- internal fun sendExternallyManagedStreamKeepAlive(request: StreamKeepAliveRequest) {
1069
- deviceManager.keepStreamAlive(request.toMap().toMutableMap())
1070
- }
1071
-
1072
1133
  suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
1073
1134
  val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
1074
1135
  pendingRgbLedRequests[request.requestId] = pending
@@ -1090,9 +1151,7 @@ class MentraBluetoothSdk private constructor(
1090
1151
 
1091
1152
  suspend fun stopStream(): StreamStatusEvent {
1092
1153
  val pending = PendingResponse<StreamStatusEvent>("stop stream")
1093
- val targetStreamId = synchronized(streamKeepAliveLock) {
1094
- activeStreamKeepAlive?.streamId
1095
- }
1154
+ val targetStreamId = streamSession.currentStreamId
1096
1155
  try {
1097
1156
  // The seq draw and the BLE hand-off share startStream's ordering
1098
1157
  // critical section: the stop takes its own slot in the send order,
@@ -1109,7 +1168,6 @@ class MentraBluetoothSdk private constructor(
1109
1168
  pendingStreamStop =
1110
1169
  PendingStreamStop(targetStreamId, streamStartSeq.incrementAndGet(), pending)
1111
1170
  }
1112
- stopStreamKeepAliveMonitor()
1113
1171
  deviceManager.stopStream()
1114
1172
  }
1115
1173
  return pending.await(STREAM_STOP_TIMEOUT_MS)
@@ -1467,7 +1525,7 @@ class MentraBluetoothSdk private constructor(
1467
1525
  }
1468
1526
 
1469
1527
  override fun close() {
1470
- stopStreamKeepAliveMonitor()
1528
+ resetWifiProtocolSession("", "sdk_closed")
1471
1529
  if (activityLifecycleCallbacksRegistered) {
1472
1530
  (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
1473
1531
  activityLifecycleCallbacks,
@@ -1483,6 +1541,9 @@ class MentraBluetoothSdk private constructor(
1483
1541
  private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
1484
1542
  when (ObservableStore.normalizeCategory(category)) {
1485
1543
  "glasses" -> {
1544
+ if (changes["connected"] == false) {
1545
+ resetWifiProtocolSession("", "wifi_session_disconnected")
1546
+ }
1486
1547
  analytics.observeGlassesStatus(getRawGlassesStatus())
1487
1548
  val state = getState()
1488
1549
  dispatchToListeners {
@@ -1691,6 +1752,27 @@ class MentraBluetoothSdk private constructor(
1691
1752
  handleWifiStatusForRequests(event)
1692
1753
  dispatchToListeners { it.onWifiStatusChanged(event) }
1693
1754
  }
1755
+ "wifi_forget_result" -> {
1756
+ handleWifiForgetResultForRequests(data)
1757
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1758
+ }
1759
+ "saved_wifi_networks" -> {
1760
+ handleSavedWifiNetworksForRequests(data)
1761
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1762
+ }
1763
+ "wifi_protocol_session_ready" -> {
1764
+ resetWifiProtocolSession(
1765
+ data["sid"] as? String ?: "",
1766
+ "wifi_session_restarted",
1767
+ )
1768
+ }
1769
+ "glasses_session_changed" -> {
1770
+ resetWifiProtocolSession(
1771
+ data["sid"] as? String ?: "",
1772
+ "wifi_session_changed",
1773
+ )
1774
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1775
+ }
1694
1776
  "wifi_scan_result" -> {
1695
1777
  val networks =
1696
1778
  (data["networks"] as? List<*>)
@@ -1759,17 +1841,18 @@ class MentraBluetoothSdk private constructor(
1759
1841
  handleRgbLedResponseForRequests(event)
1760
1842
  dispatchToListeners { it.onRgbLedControlResponse(event) }
1761
1843
  }
1844
+ "stream_control_ready" -> {
1845
+ streamSession.ready(data["sid"] as? String, (data["streamControlVersion"] as? Number)?.toInt())
1846
+ }
1762
1847
  "stream_status" -> {
1848
+ if (data.containsKey("revision") && !streamSession.accept(data)) return
1763
1849
  val event = StreamStatusEvent(data)
1764
1850
  handleStreamStatusForRequests(event)
1765
- handleStreamStatusForKeepAlive(event.status)
1766
1851
  dispatchToListeners { it.onStreamStatus(event) }
1767
1852
  }
1768
1853
  "keep_alive_ack" -> {
1769
1854
  val event = KeepAliveAckEvent(data)
1770
- if (!handleStreamKeepAliveAck(event)) {
1771
- dispatchToListeners { it.onKeepAliveAck(event) }
1772
- }
1855
+ dispatchToListeners { it.onKeepAliveAck(event) }
1773
1856
  }
1774
1857
  "ota_start_ack" -> {
1775
1858
  val event = OtaStartAckEvent.fromMap(data + mapOf("type" to "ota_start_ack"))
@@ -1800,6 +1883,9 @@ class MentraBluetoothSdk private constructor(
1800
1883
  dispatchToListeners { it.onSettingsAck(event) }
1801
1884
  }
1802
1885
  "version_info" -> {
1886
+ if (data["versionInfoType"] == "version_info_1" || data["versionInfoType"] == "version_info") {
1887
+ applyWifiProtocolCapabilities(data)
1888
+ }
1803
1889
  val event = VersionInfoResult.fromMap(data)
1804
1890
  handleVersionInfoForRequest(data)
1805
1891
  dispatchToListeners { it.onVersionInfo(event) }
@@ -1859,29 +1945,6 @@ class MentraBluetoothSdk private constructor(
1859
1945
  }
1860
1946
  }
1861
1947
 
1862
- private fun startStreamKeepAliveMonitor(streamId: String, requestedIntervalSeconds: Int) {
1863
- val intervalSeconds =
1864
- requestedIntervalSeconds.takeIf { it > 0 } ?: DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS
1865
- val tracker = ActiveStreamKeepAlive(
1866
- streamId = streamId,
1867
- intervalMs = intervalSeconds * 1_000L,
1868
- )
1869
- synchronized(streamKeepAliveLock) {
1870
- activeStreamKeepAlive = tracker
1871
- }
1872
- sendNextStreamKeepAlive(tracker)
1873
- }
1874
-
1875
- private fun stopStreamKeepAliveMonitor() {
1876
- val tracker =
1877
- synchronized(streamKeepAliveLock) {
1878
- val current = activeStreamKeepAlive
1879
- activeStreamKeepAlive = null
1880
- current
1881
- }
1882
- tracker?.nextTick?.let { mainHandler.removeCallbacks(it) }
1883
- }
1884
-
1885
1948
  private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
1886
1949
  val startMatch = matchingStreamStart(event)
1887
1950
  if (startMatch != null) {
@@ -2220,43 +2283,184 @@ class MentraBluetoothSdk private constructor(
2220
2283
  }
2221
2284
 
2222
2285
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
2223
- val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
2286
+ val connectRequest = synchronized(oneShotLock) { pendingWifiStatus }
2224
2287
  // A wifi_status carrying the explicit error field is the glasses' failure
2225
2288
  // verdict for the in-flight connect: reject now instead of running out the
2226
2289
  // request timeout. Only the error field counts as failure — the glasses'
2227
2290
  // connect sequence emits a debounced bare connected=false ~1-2s after
2228
2291
  // credentials while association is still in progress, and rejecting on that
2229
2292
  // would kill every connect attempt early.
2230
- if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
2293
+ if (connectRequest != null && event.error != null) {
2231
2294
  synchronized(oneShotLock) {
2232
- if (pendingWifiStatus === request) {
2295
+ if (pendingWifiStatus === connectRequest) {
2233
2296
  pendingWifiStatus = null
2234
2297
  }
2235
2298
  }
2236
- request.pending.reject(
2299
+ connectRequest.pending.reject(
2237
2300
  BluetoothSdkException(
2238
2301
  event.error,
2239
- "Glasses failed to join \"${request.ssid}\": ${event.error}",
2302
+ "Glasses failed to join \"${connectRequest.ssid}\": ${event.error}",
2240
2303
  )
2241
2304
  )
2242
2305
  return
2243
2306
  }
2244
- if (!wifiStatusMatches(event.status, request)) return
2307
+ if (connectRequest != null && wifiStatusMatchesConnect(event.status, connectRequest.ssid)) {
2308
+ synchronized(oneShotLock) {
2309
+ if (pendingWifiStatus === connectRequest) {
2310
+ pendingWifiStatus = null
2311
+ }
2312
+ }
2313
+ connectRequest.pending.resolve(event)
2314
+ return
2315
+ }
2316
+
2317
+ // Forget never settles from link state: modern requests require a correlated result,
2318
+ // while legacy requests resolve as unverified at accepted dispatch.
2319
+ }
2320
+
2321
+ private fun handleWifiForgetResultForRequests(data: Map<String, Any>) {
2322
+ val request = synchronized(oneShotLock) { pendingWifiForget } ?: return
2323
+ if (request.mode != WifiRequestMode.MODERN) return
2324
+ val version =
2325
+ synchronized(oneShotLock) {
2326
+ if (request.epoch != wifiSessionCapabilities.epoch) return
2327
+ (wifiSessionCapabilities.forgetResult as? WifiProtocolCapability.Supported)?.version
2328
+ } ?: return
2329
+ val result =
2330
+ parseWifiForgetResult(
2331
+ request.requestId,
2332
+ request.sid,
2333
+ request.ssid,
2334
+ version,
2335
+ data,
2336
+ ) ?: return
2337
+
2245
2338
  synchronized(oneShotLock) {
2246
- if (pendingWifiStatus === request) {
2247
- pendingWifiStatus = null
2339
+ if (pendingWifiForget !== request || request.epoch != wifiSessionCapabilities.epoch) {
2340
+ return
2248
2341
  }
2342
+ pendingWifiForget = null
2249
2343
  }
2250
- request.pending.resolve(event)
2344
+ request.pending.resolve(result)
2345
+ }
2346
+
2347
+ private fun handleSavedWifiNetworksForRequests(data: Map<String, Any>) {
2348
+ val request = synchronized(oneShotLock) { pendingSavedWifiNetworks } ?: return
2349
+ if (request.mode != WifiRequestMode.MODERN) return
2350
+ val version =
2351
+ synchronized(oneShotLock) {
2352
+ if (request.epoch != wifiSessionCapabilities.epoch) return
2353
+ (wifiSessionCapabilities.savedNetworks as? WifiProtocolCapability.Supported)?.version
2354
+ } ?: return
2355
+ val result =
2356
+ parseSavedWifiNetworks(
2357
+ request.requestId,
2358
+ request.sid,
2359
+ version,
2360
+ data,
2361
+ ) ?: return
2362
+ synchronized(oneShotLock) {
2363
+ if (pendingSavedWifiNetworks !== request || request.epoch != wifiSessionCapabilities.epoch) {
2364
+ return
2365
+ }
2366
+ pendingSavedWifiNetworks = null
2367
+ }
2368
+ request.pending.resolve(result)
2369
+ }
2370
+
2371
+ private fun wifiStatusMatchesConnect(status: WifiStatus, ssid: String): Boolean =
2372
+ status is WifiStatus.Connected && status.ssid == ssid
2373
+
2374
+ private fun dispatchWifiForgetIfReady(request: PendingWifiForgetRequest) {
2375
+ synchronized(oneShotLock) {
2376
+ if (pendingWifiForget !== request || request.epoch != wifiSessionCapabilities.epoch ||
2377
+ request.mode == WifiRequestMode.DISCOVERING || request.commandSent) return
2378
+ if (request.mode == WifiRequestMode.UNSUPPORTED) {
2379
+ request.pending.reject(BluetoothSdkException("wifi_protocol_unsupported", "Unsupported WiFi forget protocol version."))
2380
+ return
2381
+ }
2382
+ request.sid = wifiSessionCapabilities.sessionId
2383
+ request.commandSent = deviceManager.forgetWifiNetwork(
2384
+ request.ssid,
2385
+ request.requestId.takeIf { request.mode == WifiRequestMode.MODERN },
2386
+ request.sid.takeIf { request.mode == WifiRequestMode.MODERN },
2387
+ )
2388
+ if (!request.commandSent) {
2389
+ request.pending.reject(BluetoothSdkException("dispatch_failed", "No active glasses transport accepted WiFi forget."))
2390
+ } else if (request.mode == WifiRequestMode.LEGACY) {
2391
+ pendingWifiForget = null
2392
+ request.pending.resolve(legacyWifiForgetResult(request.ssid))
2393
+ }
2394
+ }
2395
+ }
2396
+
2397
+ private fun dispatchSavedWifiNetworksIfReady(request: PendingSavedWifiNetworks) {
2398
+ synchronized(oneShotLock) {
2399
+ if (pendingSavedWifiNetworks !== request || request.epoch != wifiSessionCapabilities.epoch ||
2400
+ request.mode != WifiRequestMode.MODERN || request.commandSent) return
2401
+ request.sid = wifiSessionCapabilities.sessionId
2402
+ request.commandSent = deviceManager.requestSavedWifiNetworks(request.requestId, request.sid)
2403
+ if (!request.commandSent) {
2404
+ request.pending.reject(BluetoothSdkException("dispatch_failed", "No active glasses transport accepted saved WiFi request."))
2405
+ }
2406
+ }
2407
+ }
2408
+
2409
+ private fun applyWifiProtocolCapabilities(data: Map<String, Any>) {
2410
+ var forgetToDispatch: PendingWifiForgetRequest? = null
2411
+ var savedToDispatch: PendingSavedWifiNetworks? = null
2412
+ var unsupportedSaved: PendingSavedWifiNetworks? = null
2413
+ synchronized(oneShotLock) {
2414
+ wifiSessionCapabilities.applyVersionInfo1(data)
2415
+ pendingWifiForget?.takeIf { it.epoch == wifiSessionCapabilities.epoch }?.let { request ->
2416
+ if (request.mode == WifiRequestMode.DISCOVERING) {
2417
+ request.mode = wifiSessionCapabilities.forgetMode()
2418
+ forgetToDispatch = request
2419
+ }
2420
+ }
2421
+ pendingSavedWifiNetworks?.takeIf { it.epoch == wifiSessionCapabilities.epoch }?.let { request ->
2422
+ if (request.mode == WifiRequestMode.DISCOVERING) {
2423
+ request.mode = wifiSessionCapabilities.savedNetworksMode()
2424
+ if (request.mode == WifiRequestMode.LEGACY || request.mode == WifiRequestMode.UNSUPPORTED) {
2425
+ pendingSavedWifiNetworks = null
2426
+ unsupportedSaved = request
2427
+ } else {
2428
+ savedToDispatch = request
2429
+ }
2430
+ }
2431
+ }
2432
+ }
2433
+ unsupportedSaved?.let { request ->
2434
+ request.pending.resolve(
2435
+ SavedWifiNetworksResult(
2436
+ outcome = SavedWifiNetworksOutcome.UNSUPPORTED,
2437
+ networks = emptyList(),
2438
+ error = "saved_wifi_networks_unsupported",
2439
+ )
2440
+ )
2441
+ }
2442
+ forgetToDispatch?.let(::dispatchWifiForgetIfReady)
2443
+ savedToDispatch?.let(::dispatchSavedWifiNetworksIfReady)
2251
2444
  }
2252
2445
 
2253
- private fun wifiStatusMatches(status: WifiStatus, request: PendingWifiStatusRequest): Boolean =
2254
- when (request.operation) {
2255
- WifiStatusOperation.CONNECT ->
2256
- status is WifiStatus.Connected && status.ssid == request.ssid
2257
- WifiStatusOperation.FORGET ->
2258
- status == WifiStatus.Disconnected || (status is WifiStatus.Connected && status.ssid != request.ssid)
2446
+ private fun resetWifiProtocolSession(sessionId: String, code: String) {
2447
+ val error = BluetoothSdkException(code, "The glasses WiFi protocol session changed.")
2448
+ val pendingToReject = mutableListOf<PendingResponse<*>>()
2449
+ synchronized(oneShotLock) {
2450
+ wifiSessionCapabilities.reset(sessionId)
2451
+ pendingWifiStatus?.pending?.let(pendingToReject::add)
2452
+ pendingWifiForget?.pending?.let(pendingToReject::add)
2453
+ pendingSavedWifiNetworks?.pending?.let(pendingToReject::add)
2454
+ pendingWifiScan?.pending?.let(pendingToReject::add)
2455
+ pendingHotspotStatus?.pending?.let(pendingToReject::add)
2456
+ pendingWifiStatus = null
2457
+ pendingWifiForget = null
2458
+ pendingSavedWifiNetworks = null
2459
+ pendingWifiScan = null
2460
+ pendingHotspotStatus = null
2259
2461
  }
2462
+ pendingToReject.forEach { it.reject(error) }
2463
+ }
2260
2464
 
2261
2465
  private fun handleHotspotStatusForRequests(event: HotspotStatusEvent) {
2262
2466
  val request = synchronized(oneShotLock) { pendingHotspotStatus } ?: return
@@ -2291,94 +2495,7 @@ class MentraBluetoothSdk private constructor(
2291
2495
  )
2292
2496
  }
2293
2497
 
2294
- private fun sendNextStreamKeepAlive(tracker: ActiveStreamKeepAlive) {
2295
- var timeoutEvent: StreamStatusEvent? = null
2296
- var request: StreamKeepAliveRequest? = null
2297
-
2298
- synchronized(streamKeepAliveLock) {
2299
- if (activeStreamKeepAlive !== tracker) {
2300
- return
2301
- }
2302
-
2303
- if (tracker.armed && tracker.pendingAckId != null) {
2304
- tracker.missedAckCount += 1
2305
- if (tracker.missedAckCount >= MAX_MISSED_STREAM_KEEP_ALIVE_ACKS) {
2306
- activeStreamKeepAlive = null
2307
- tracker.nextTick?.let { mainHandler.removeCallbacks(it) }
2308
- timeoutEvent =
2309
- StreamStatusEvent(
2310
- StreamStatus.Error(
2311
- streamId = tracker.streamId,
2312
- errorDetails =
2313
- "Stream keep-alive timed out after ${tracker.missedAckCount} missed ACKs",
2314
- timestamp = System.currentTimeMillis(),
2315
- resolvedConfig = null,
2316
- )
2317
- )
2318
- return@synchronized
2319
- }
2320
- }
2321
-
2322
- val ackId = "ack-${System.currentTimeMillis()}"
2323
- tracker.pendingAckId = ackId
2324
- request = StreamKeepAliveRequest(streamId = tracker.streamId, ackId = ackId)
2325
- val nextTick = Runnable { sendNextStreamKeepAlive(tracker) }
2326
- tracker.nextTick = nextTick
2327
- mainHandler.postDelayed(nextTick, tracker.intervalMs)
2328
- }
2329
-
2330
- timeoutEvent?.let { event ->
2331
- dispatchToListeners { it.onStreamStatus(event) }
2332
- stopStreamKeepAliveMonitor()
2333
- deviceManager.stopStream()
2334
- return
2335
- }
2336
-
2337
- request?.let { keepAlive ->
2338
- deviceManager.keepStreamAlive(keepAlive.toMap().toMutableMap())
2339
- }
2340
- }
2341
-
2342
- private fun handleStreamKeepAliveAck(event: KeepAliveAckEvent): Boolean {
2343
- synchronized(streamKeepAliveLock) {
2344
- val tracker = activeStreamKeepAlive ?: return false
2345
- if (event.streamId != tracker.streamId || event.ackId != tracker.pendingAckId) {
2346
- return false
2347
- }
2348
- tracker.pendingAckId = null
2349
- tracker.missedAckCount = 0
2350
- return true
2351
- }
2352
- }
2353
2498
 
2354
- private fun handleStreamStatusForKeepAlive(status: StreamStatus) {
2355
- val streamId = status.streamId
2356
- val activeStreamId = synchronized(streamKeepAliveLock) { activeStreamKeepAlive?.streamId }
2357
- if (streamId == null || activeStreamId != streamId) {
2358
- return
2359
- }
2360
- when (status.state) {
2361
- StreamState.STOPPED,
2362
- StreamState.STOPPING,
2363
- StreamState.ERROR,
2364
- StreamState.RECONNECT_FAILED -> stopStreamKeepAliveMonitor()
2365
- // A non-terminal status means the stream is live or coming up and the glasses can
2366
- // now ACK; arm the missed-ACK detector from here so a slow startup before the first
2367
- // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
2368
- // pre-arm bookkeeping so a stale unacked id (sent before the glasses could ACK)
2369
- // can't immediately count as a miss.
2370
- else ->
2371
- synchronized(streamKeepAliveLock) {
2372
- activeStreamKeepAlive?.let {
2373
- if (it.streamId == streamId && !it.armed) {
2374
- it.armed = true
2375
- it.pendingAckId = null
2376
- it.missedAckCount = 0
2377
- }
2378
- }
2379
- }
2380
- }
2381
- }
2382
2499
  }
2383
2500
 
2384
2501
  /** OTA status messages are not request-correlated, so only known pre-ack failures settle a start. */