@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.
Files changed (50) hide show
  1. package/README.md +46 -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 +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 +285 -29
  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 +312 -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/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  18. package/build/BluetoothSdk.types.d.ts +62 -1
  19. package/build/BluetoothSdk.types.d.ts.map +1 -1
  20. package/build/BluetoothSdk.types.js.map +1 -1
  21. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  22. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  23. package/build/_private/BluetoothSdkModule.js.map +1 -1
  24. package/build/generated/changelogCatalog.d.ts +1 -1
  25. package/build/generated/changelogCatalog.js +1 -1
  26. package/build/generated/changelogCatalog.js.map +1 -1
  27. package/build/generated/releaseMetadata.js +5 -5
  28. package/build/generated/releaseMetadata.js.map +1 -1
  29. package/build/index.d.ts +1 -1
  30. package/build/index.d.ts.map +1 -1
  31. package/build/index.js +3 -0
  32. package/build/index.js.map +1 -1
  33. package/ios/BluetoothSdkModule.swift +8 -2
  34. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  35. package/ios/Source/Bridge.swift +63 -0
  36. package/ios/Source/DeviceManager.swift +6 -3
  37. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  38. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  39. package/ios/Source/MentraBluetoothSDK.swift +365 -93
  40. package/ios/Source/WifiRequestResolution.swift +251 -0
  41. package/ios/Source/sgcs/MentraLive.swift +170 -35
  42. package/ios/Source/sgcs/SGCManager.swift +13 -1
  43. package/ios/Source/status/DeviceStatus.swift +28 -0
  44. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  45. package/package.json +1 -1
  46. package/src/BluetoothSdk.types.ts +76 -1
  47. package/src/_private/BluetoothSdkModule.ts +4 -1
  48. package/src/generated/changelogCatalog.ts +1 -1
  49. package/src/generated/releaseMetadata.ts +5 -5
  50. 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
76
  private var pendingVersionInfo: PendingVersionInfoRequest? = null
77
+ private val wifiSessionCapabilities = WifiSessionCapabilities()
75
78
  @Volatile private var configuredOtaVersionUrl: String? = null
76
79
 
77
80
  init {
@@ -195,15 +198,28 @@ class MentraBluetoothSdk private constructor(
195
198
  )
196
199
 
197
200
  private data class PendingWifiStatusRequest(
198
- val operation: WifiStatusOperation,
199
201
  val ssid: String,
200
202
  val pending: PendingResponse<WifiStatusEvent>,
201
203
  )
202
204
 
203
- private enum class WifiStatusOperation {
204
- CONNECT,
205
- FORGET,
206
- }
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
+ )
207
223
 
208
224
  private data class PendingHotspotStatusRequest(
209
225
  val enabled: Boolean,
@@ -239,6 +255,7 @@ class MentraBluetoothSdk private constructor(
239
255
  "$operation timed out waiting for glasses response.",
240
256
  )
241
257
  }
258
+
242
259
  }
243
260
 
244
261
  fun addListener(listener: MentraBluetoothSdkListener) {
@@ -880,13 +897,13 @@ class MentraBluetoothSdk private constructor(
880
897
  suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
881
898
  val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
882
899
  synchronized(oneShotLock) {
883
- if (pendingWifiStatus != null) {
900
+ if (pendingWifiStatus != null || pendingWifiForget != null) {
884
901
  throw BluetoothSdkException(
885
902
  "request_in_flight",
886
903
  "A WiFi status command is already waiting for a glasses response.",
887
904
  )
888
905
  }
889
- pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.CONNECT, ssid, pending)
906
+ pendingWifiStatus = PendingWifiStatusRequest(ssid, pending)
890
907
  }
891
908
  try {
892
909
  deviceManager.sendWifiCredentials(ssid, password)
@@ -900,24 +917,94 @@ class MentraBluetoothSdk private constructor(
900
917
  }
901
918
  }
902
919
 
903
- suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
904
- 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
905
927
  synchronized(oneShotLock) {
906
- if (pendingWifiStatus != null) {
928
+ if (pendingWifiStatus != null || pendingWifiForget != null) {
907
929
  throw BluetoothSdkException(
908
930
  "request_in_flight",
909
931
  "A WiFi status command is already waiting for a glasses response.",
910
932
  )
911
933
  }
912
- 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
913
944
  }
914
945
  try {
915
- deviceManager.forgetWifiNetwork(ssid)
946
+ dispatchWifiForgetIfReady(request)
916
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
917
953
  } finally {
918
954
  synchronized(oneShotLock) {
919
- if (pendingWifiStatus?.pending === pending) {
920
- 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
921
1008
  }
922
1009
  }
923
1010
  }
@@ -1468,6 +1555,7 @@ class MentraBluetoothSdk private constructor(
1468
1555
 
1469
1556
  override fun close() {
1470
1557
  stopStreamKeepAliveMonitor()
1558
+ resetWifiProtocolSession("", "sdk_closed")
1471
1559
  if (activityLifecycleCallbacksRegistered) {
1472
1560
  (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
1473
1561
  activityLifecycleCallbacks,
@@ -1483,6 +1571,9 @@ class MentraBluetoothSdk private constructor(
1483
1571
  private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
1484
1572
  when (ObservableStore.normalizeCategory(category)) {
1485
1573
  "glasses" -> {
1574
+ if (changes["connected"] == false) {
1575
+ resetWifiProtocolSession("", "wifi_session_disconnected")
1576
+ }
1486
1577
  analytics.observeGlassesStatus(getRawGlassesStatus())
1487
1578
  val state = getState()
1488
1579
  dispatchToListeners {
@@ -1691,6 +1782,27 @@ class MentraBluetoothSdk private constructor(
1691
1782
  handleWifiStatusForRequests(event)
1692
1783
  dispatchToListeners { it.onWifiStatusChanged(event) }
1693
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
+ }
1694
1806
  "wifi_scan_result" -> {
1695
1807
  val networks =
1696
1808
  (data["networks"] as? List<*>)
@@ -1800,6 +1912,9 @@ class MentraBluetoothSdk private constructor(
1800
1912
  dispatchToListeners { it.onSettingsAck(event) }
1801
1913
  }
1802
1914
  "version_info" -> {
1915
+ if (data["versionInfoType"] == "version_info_1" || data["versionInfoType"] == "version_info") {
1916
+ applyWifiProtocolCapabilities(data)
1917
+ }
1803
1918
  val event = VersionInfoResult.fromMap(data)
1804
1919
  handleVersionInfoForRequest(data)
1805
1920
  dispatchToListeners { it.onVersionInfo(event) }
@@ -2220,43 +2335,184 @@ class MentraBluetoothSdk private constructor(
2220
2335
  }
2221
2336
 
2222
2337
  private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
2223
- val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
2338
+ val connectRequest = synchronized(oneShotLock) { pendingWifiStatus }
2224
2339
  // A wifi_status carrying the explicit error field is the glasses' failure
2225
2340
  // verdict for the in-flight connect: reject now instead of running out the
2226
2341
  // request timeout. Only the error field counts as failure — the glasses'
2227
2342
  // connect sequence emits a debounced bare connected=false ~1-2s after
2228
2343
  // credentials while association is still in progress, and rejecting on that
2229
2344
  // would kill every connect attempt early.
2230
- if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
2345
+ if (connectRequest != null && event.error != null) {
2231
2346
  synchronized(oneShotLock) {
2232
- if (pendingWifiStatus === request) {
2347
+ if (pendingWifiStatus === connectRequest) {
2233
2348
  pendingWifiStatus = null
2234
2349
  }
2235
2350
  }
2236
- request.pending.reject(
2351
+ connectRequest.pending.reject(
2237
2352
  BluetoothSdkException(
2238
2353
  event.error,
2239
- "Glasses failed to join \"${request.ssid}\": ${event.error}",
2354
+ "Glasses failed to join \"${connectRequest.ssid}\": ${event.error}",
2240
2355
  )
2241
2356
  )
2242
2357
  return
2243
2358
  }
2244
- 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
+
2245
2390
  synchronized(oneShotLock) {
2246
- if (pendingWifiStatus === request) {
2247
- pendingWifiStatus = null
2391
+ if (pendingWifiForget !== request || request.epoch != wifiSessionCapabilities.epoch) {
2392
+ return
2393
+ }
2394
+ pendingWifiForget = null
2395
+ }
2396
+ request.pending.resolve(result)
2397
+ }
2398
+
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
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))
2248
2445
  }
2249
2446
  }
2250
- request.pending.resolve(event)
2251
2447
  }
2252
2448
 
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)
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
+ }
2259
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
+ }
2260
2516
 
2261
2517
  private fun handleHotspotStatusForRequests(event: HotspotStatusEvent) {
2262
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,246 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal fun wifiSsidIsValid(ssid: String): Boolean = ssid.trim().isNotEmpty()
4
+
5
+ /** Validate raw fields before JSON convenience getters can coerce numbers or erase presence. */
6
+ internal fun wifiResponseEnvelopeIsValid(values: Map<String, Any>, allowLegacy: Boolean): Boolean {
7
+ if (values.containsKey("connected") && values["connected"] !is Boolean) return false
8
+ if (values.containsKey("dispatched") && values["dispatched"] !is Boolean) return false
9
+ val hasTuple = listOf("protocol_version", "requestId", "sid").any(values::containsKey)
10
+ if (!hasTuple) return allowLegacy && !values.containsKey("outcome")
11
+ return (values["protocol_version"] as? Number)?.toDouble() == 1.0 &&
12
+ (values["requestId"] as? String)?.isNotBlank() == true &&
13
+ (values["sid"] as? String)?.isNotBlank() == true &&
14
+ !values.containsKey("dispatched")
15
+ }
16
+
17
+ internal const val WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE = "capability_negotiation_timeout"
18
+
19
+ internal sealed class WifiProtocolCapability {
20
+ data object Unknown : WifiProtocolCapability()
21
+
22
+ data class Supported(val version: Int) : WifiProtocolCapability()
23
+
24
+ data object Unsupported : WifiProtocolCapability()
25
+
26
+ data object Legacy : WifiProtocolCapability()
27
+ }
28
+
29
+ internal enum class WifiRequestMode {
30
+ DISCOVERING,
31
+ MODERN,
32
+ LEGACY,
33
+ UNSUPPORTED,
34
+ }
35
+
36
+ internal data class WifiSessionRequestSnapshot(
37
+ val mode: WifiRequestMode,
38
+ val sessionId: String,
39
+ val epoch: Long,
40
+ )
41
+
42
+ internal class WifiSessionCapabilities {
43
+ var sessionId: String = ""
44
+ private set
45
+ var epoch: Long = 0
46
+ private set
47
+ var forgetResult: WifiProtocolCapability = WifiProtocolCapability.Unknown
48
+ private set
49
+ var savedNetworks: WifiProtocolCapability = WifiProtocolCapability.Unknown
50
+ private set
51
+
52
+ fun reset(sessionId: String = "") {
53
+ epoch += 1
54
+ this.sessionId = sessionId
55
+ forgetResult = WifiProtocolCapability.Unknown
56
+ savedNetworks = WifiProtocolCapability.Unknown
57
+ }
58
+
59
+ fun applyVersionInfo1(values: Map<String, Any>) {
60
+ (values["sid"] as? String)?.takeIf { it.isNotEmpty() }?.let { sessionId = it }
61
+ forgetResult = capability(values["wifiForgetResultVersion"])
62
+ savedNetworks = capability(values["savedWifiNetworksVersion"])
63
+ }
64
+
65
+ fun forgetMode(): WifiRequestMode = requestMode(forgetResult)
66
+
67
+ fun savedNetworksMode(): WifiRequestMode = requestMode(savedNetworks)
68
+
69
+ fun savedNetworksRequestSnapshot(): WifiSessionRequestSnapshot =
70
+ WifiSessionRequestSnapshot(savedNetworksMode(), sessionId, epoch)
71
+
72
+ private fun capability(raw: Any?): WifiProtocolCapability {
73
+ if (raw == null) return WifiProtocolCapability.Legacy
74
+ return if (raw is Number && raw.toDouble() == 1.0 && sessionId.isNotEmpty()) {
75
+ WifiProtocolCapability.Supported(1)
76
+ } else WifiProtocolCapability.Unsupported
77
+ }
78
+
79
+ private fun requestMode(capability: WifiProtocolCapability): WifiRequestMode =
80
+ when (capability) {
81
+ WifiProtocolCapability.Unknown -> WifiRequestMode.DISCOVERING
82
+ is WifiProtocolCapability.Supported -> WifiRequestMode.MODERN
83
+ WifiProtocolCapability.Legacy -> WifiRequestMode.LEGACY
84
+ WifiProtocolCapability.Unsupported -> WifiRequestMode.UNSUPPORTED
85
+ }
86
+ }
87
+
88
+ enum class WifiForgetOutcome(val wireValue: String) {
89
+ CONFIRMED("confirmed"),
90
+ DISPATCHED("dispatched"),
91
+ NOT_FOUND("not_found"),
92
+ UNSUPPORTED("unsupported"),
93
+ FAILED("failed"),
94
+ LEGACY_UNVERIFIED("legacy_unverified");
95
+
96
+ companion object {
97
+ internal fun fromWire(value: String): WifiForgetOutcome? =
98
+ entries.find { it.wireValue == value }
99
+ }
100
+ }
101
+
102
+ internal fun normalizeWifiForgetResultEvent(
103
+ requestId: String,
104
+ sid: String,
105
+ ssid: String,
106
+ protocolVersion: Int,
107
+ outcome: String,
108
+ legacyDispatched: Boolean?,
109
+ connected: Boolean?,
110
+ currentSsid: String,
111
+ localIp: String,
112
+ error: String?,
113
+ ): Map<String, Any>? {
114
+ if (!wifiSsidIsValid(ssid)) return null
115
+ val modernOutcome = WifiForgetOutcome.fromWire(outcome)
116
+ if (requestId.isNotEmpty() && sid.isNotEmpty() && protocolVersion == 1 &&
117
+ modernOutcome != null && modernOutcome != WifiForgetOutcome.LEGACY_UNVERIFIED
118
+ ) {
119
+ return buildMap {
120
+ put("mode", "modern")
121
+ put("requestId", requestId)
122
+ put("sid", sid)
123
+ put("ssid", ssid)
124
+ put("protocolVersion", protocolVersion)
125
+ put("outcome", modernOutcome.wireValue)
126
+ connected?.let { put("connected", it) }
127
+ if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
128
+ if (localIp.isNotEmpty()) put("localIp", localIp)
129
+ error?.let { put("error", it) }
130
+ }
131
+ }
132
+ if (requestId.isEmpty() && sid.isEmpty() && protocolVersion == 0 && outcome.isEmpty() && legacyDispatched != null) {
133
+ return buildMap {
134
+ put("mode", "legacy")
135
+ put("ssid", ssid)
136
+ put("dispatched", legacyDispatched)
137
+ connected?.let { put("connected", it) }
138
+ if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
139
+ if (localIp.isNotEmpty()) put("localIp", localIp)
140
+ error?.let { put("error", it) }
141
+ }
142
+ }
143
+ return null
144
+ }
145
+
146
+ data class WifiForgetResult(
147
+ val ssid: String,
148
+ val outcome: WifiForgetOutcome,
149
+ val connected: Boolean?,
150
+ val currentSsid: String?,
151
+ val localIp: String?,
152
+ val error: String? = null,
153
+ ) {
154
+ internal fun toMap(): Map<String, Any> =
155
+ buildMap {
156
+ put("ssid", ssid)
157
+ put("outcome", outcome.wireValue)
158
+ connected?.let { put("connected", it) }
159
+ currentSsid?.let { put("currentSsid", it) }
160
+ localIp?.let { put("localIp", it) }
161
+ error?.let { put("error", it) }
162
+ }
163
+ }
164
+
165
+ enum class SavedWifiNetworksOutcome(val wireValue: String) {
166
+ CONFIRMED("confirmed"),
167
+ UNSUPPORTED("unsupported"),
168
+ FAILED("failed");
169
+
170
+ companion object {
171
+ internal fun fromWire(value: String): SavedWifiNetworksOutcome? =
172
+ entries.find { it.wireValue == value }
173
+ }
174
+ }
175
+
176
+ data class SavedWifiNetworksResult(
177
+ val outcome: SavedWifiNetworksOutcome,
178
+ val networks: List<String>,
179
+ val error: String? = null,
180
+ ) {
181
+ internal fun toMap(): Map<String, Any> =
182
+ buildMap {
183
+ put("outcome", outcome.wireValue)
184
+ put("networks", networks)
185
+ error?.let { put("error", it) }
186
+ }
187
+ }
188
+
189
+ internal fun parseWifiForgetResult(
190
+ expectedRequestId: String,
191
+ expectedSid: String,
192
+ expectedSsid: String,
193
+ capabilityVersion: Int,
194
+ data: Map<String, Any>,
195
+ ): WifiForgetResult? {
196
+ if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
197
+ if (data["requestId"] as? String != expectedRequestId) return null
198
+ if (data["sid"] as? String != expectedSid) return null
199
+ if (data["ssid"] as? String != expectedSsid) return null
200
+ if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
201
+ val outcome = WifiForgetOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
202
+ if (outcome == WifiForgetOutcome.LEGACY_UNVERIFIED) return null
203
+ return WifiForgetResult(
204
+ ssid = expectedSsid,
205
+ outcome = outcome,
206
+ connected = data["connected"] as? Boolean,
207
+ currentSsid = data["currentSsid"] as? String,
208
+ localIp = data["localIp"] as? String,
209
+ error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
210
+ )
211
+ }
212
+
213
+ internal fun parseSavedWifiNetworks(
214
+ expectedRequestId: String,
215
+ expectedSid: String,
216
+ capabilityVersion: Int,
217
+ data: Map<String, Any>,
218
+ ): SavedWifiNetworksResult? {
219
+ if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
220
+ if (data["requestId"] as? String != expectedRequestId) return null
221
+ if (data["sid"] as? String != expectedSid) return null
222
+ if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
223
+ val outcome =
224
+ SavedWifiNetworksOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
225
+ val rawNetworks = data["networks"] as? List<*> ?: return null
226
+ if (rawNetworks.any { it !is String }) return null
227
+ if (outcome != SavedWifiNetworksOutcome.CONFIRMED && rawNetworks.isNotEmpty()) return null
228
+ val networks = rawNetworks.map { it as String }.filter { it.trim().isNotEmpty() }.distinct()
229
+ return SavedWifiNetworksResult(
230
+ outcome = outcome,
231
+ networks = networks,
232
+ error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
233
+ )
234
+ }
235
+
236
+ internal fun legacyWifiForgetResult(
237
+ ssid: String,
238
+ ): WifiForgetResult {
239
+ return WifiForgetResult(
240
+ ssid = ssid,
241
+ outcome = WifiForgetOutcome.LEGACY_UNVERIFIED,
242
+ connected = null,
243
+ currentSsid = null,
244
+ localIp = null,
245
+ )
246
+ }