@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7

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 (114) hide show
  1. package/README.md +38 -2
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1174 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  27. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  33. package/build/BluetoothSdk.types.d.ts +61 -2
  34. package/build/BluetoothSdk.types.d.ts.map +1 -1
  35. package/build/BluetoothSdk.types.js.map +1 -1
  36. package/build/_internal.d.ts +2 -0
  37. package/build/_internal.d.ts.map +1 -1
  38. package/build/_internal.js +2 -0
  39. package/build/_internal.js.map +1 -1
  40. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  41. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  42. package/build/_private/BluetoothSdkModule.js +1 -1
  43. package/build/_private/BluetoothSdkModule.js.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  45. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  47. package/build/_private/sdkOtaManifest.d.ts +3 -12
  48. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  49. package/build/_private/sdkOtaManifest.js +5 -17
  50. package/build/_private/sdkOtaManifest.js.map +1 -1
  51. package/build/generated/releaseMetadata.d.ts +12 -0
  52. package/build/generated/releaseMetadata.d.ts.map +1 -0
  53. package/build/generated/releaseMetadata.js +10 -0
  54. package/build/generated/releaseMetadata.js.map +1 -0
  55. package/build/index.d.ts +1 -1
  56. package/build/index.d.ts.map +1 -1
  57. package/build/index.js +10 -2
  58. package/build/index.js.map +1 -1
  59. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  60. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  61. package/build/ota-server/MentraOtaServer.types.js +2 -0
  62. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  63. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  64. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.js +3 -0
  66. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  68. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  70. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  71. package/build/ota-server/index.d.ts +3 -0
  72. package/build/ota-server/index.d.ts.map +1 -0
  73. package/build/ota-server/index.js +3 -0
  74. package/build/ota-server/index.js.map +1 -0
  75. package/expo-module.config.json +3 -2
  76. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  77. package/ios/BluetoothSdkModule.swift +16 -2
  78. package/ios/LocalIPv4.swift +150 -0
  79. package/ios/LocalOtaServer.swift +309 -0
  80. package/ios/MentraBluetoothSDK.podspec +5 -1
  81. package/ios/MentraOtaServerModule.swift +147 -0
  82. package/ios/MentraPhotoReceiverModule.swift +1 -81
  83. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  84. package/ios/Source/Bridge.swift +47 -3
  85. package/ios/Source/DeviceManager.swift +135 -10
  86. package/ios/Source/DeviceStore.swift +2 -0
  87. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  88. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  89. package/ios/Source/OtaManifest.swift +5 -7
  90. package/ios/Source/audio/AudioModels.swift +17 -0
  91. package/ios/Source/camera/CameraModels.swift +1 -0
  92. package/ios/Source/events/BluetoothEvents.swift +27 -0
  93. package/ios/Source/sgcs/MentraLive.swift +439 -30
  94. package/ios/Source/sgcs/SGCManager.swift +11 -0
  95. package/ios/Source/status/DeviceStatus.swift +23 -0
  96. package/ios/Source/types/DeviceModels.swift +23 -2
  97. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  98. package/package.json +5 -1
  99. package/plugin/build/withIos.d.ts +3 -1
  100. package/plugin/build/withIos.js +22 -10
  101. package/scripts/verify-release-package.mjs +34 -0
  102. package/scripts/write-release-metadata.mjs +154 -0
  103. package/scripts/write-release-metadata.test.mjs +29 -0
  104. package/src/BluetoothSdk.types.ts +72 -3
  105. package/src/_internal.ts +2 -0
  106. package/src/_private/BluetoothSdkModule.ts +5 -5
  107. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  108. package/src/_private/sdkOtaManifest.ts +5 -17
  109. package/src/generated/releaseMetadata.ts +20 -0
  110. package/src/index.ts +19 -2
  111. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  112. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  113. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  114. package/src/ota-server/index.ts +2 -0
@@ -89,6 +89,14 @@ class DeviceManager {
89
89
  get() = DeviceStore.store.get("bluetooth", "pending_wearable") as? String ?: ""
90
90
  set(value) = DeviceStore.apply("bluetooth", "pending_wearable", value)
91
91
 
92
+ private var pendingDeviceName: String
93
+ get() = DeviceStore.store.get("bluetooth", "pending_device_name") as? String ?: ""
94
+ set(value) = DeviceStore.apply("bluetooth", "pending_device_name", value)
95
+
96
+ private var pendingDeviceAddress: String
97
+ get() = DeviceStore.store.get("bluetooth", "pending_device_address") as? String ?: ""
98
+ set(value) = DeviceStore.apply("bluetooth", "pending_device_address", value)
99
+
92
100
  public var deviceName: String
93
101
  get() = DeviceStore.store.get("bluetooth", "device_name") as? String ?: ""
94
102
  set(value) = DeviceStore.apply("bluetooth", "device_name", value)
@@ -205,6 +213,7 @@ class DeviceManager {
205
213
  private var lastReadyHandledAtMs: Long = 0L
206
214
  private var lastReadyHandledKey: String = ""
207
215
  private var lastSystemTimeSyncConnectionKey: String = ""
216
+ private var pendingSystemTimeSync: Runnable? = null
208
217
 
209
218
  private var systemMicUnavailable: Boolean
210
219
  get() = DeviceStore.store.get("bluetooth", "systemMicUnavailable") as? Boolean ?: false
@@ -250,6 +259,12 @@ class DeviceManager {
250
259
  // Audio output format - defaults to LC3 for bandwidth savings
251
260
  private var audioOutputFormat: AudioOutputFormat = AudioOutputFormat.LC3
252
261
  private var lastLc3Event: Long? = null
262
+ private val micHealthLock = Any()
263
+ private var sequenceGapEvents = 0L
264
+ private var decodeFailures = 0L
265
+ private var lastLc3ReceivedAt: Long? = null
266
+ private var lastPcmProducedAt: Long? = null
267
+ private var lastLc3Sequence: Int? = null
253
268
  private var micReinitRunnable: Runnable? = null
254
269
  private var systemMicAvailabilityRecheckRunnable: Runnable? = null
255
270
 
@@ -742,7 +757,69 @@ class DeviceManager {
742
757
  * this per uplink packet — otherwise the watchdog keeps re-enabling a working mic.
743
758
  */
744
759
  fun reportGlassesAudioActivity() {
745
- lastLc3Event = System.currentTimeMillis()
760
+ val now = System.currentTimeMillis()
761
+ lastLc3Event = now
762
+ synchronized(micHealthLock) {
763
+ lastPcmProducedAt = now
764
+ }
765
+ }
766
+
767
+ private fun micHealthSnapshotLocked(): MicHealth =
768
+ MicHealth(
769
+ sequenceGapEvents = sequenceGapEvents,
770
+ decodeFailures = decodeFailures,
771
+ lastLc3ReceivedAt = lastLc3ReceivedAt,
772
+ lastPcmProducedAt = lastPcmProducedAt,
773
+ )
774
+
775
+ private fun resetMicHealth() {
776
+ synchronized(micHealthLock) {
777
+ sequenceGapEvents = 0
778
+ decodeFailures = 0
779
+ lastLc3ReceivedAt = null
780
+ lastPcmProducedAt = null
781
+ lastLc3Sequence = null
782
+ }
783
+ lastLc3Event = null
784
+ }
785
+
786
+ fun resetMicSequenceBaseline() {
787
+ synchronized(micHealthLock) { lastLc3Sequence = null }
788
+ }
789
+
790
+ private fun recordLc3Packet(sequenceNumber: Int?) {
791
+ var gap = false
792
+ val health = synchronized(micHealthLock) {
793
+ val now = System.currentTimeMillis()
794
+ lastLc3ReceivedAt = now
795
+ lastLc3Event = now
796
+ if (sequenceNumber != null) {
797
+ val normalized = sequenceNumber and 0xff
798
+ lastLc3Sequence?.let { previous ->
799
+ val expected = (previous + 1) and 0xff
800
+ if (normalized != expected) {
801
+ gap = true
802
+ sequenceGapEvents += 1
803
+ Bridge.log("MAN: LC3 packet sequence mismatch. Expected: $expected, Got: $normalized")
804
+ }
805
+ }
806
+ lastLc3Sequence = normalized
807
+ }
808
+ micHealthSnapshotLocked()
809
+ }
810
+ if (gap) Bridge.sendMicHealth(health, "sequence_gap")
811
+ }
812
+
813
+ private fun recordMicDecodeFailure() {
814
+ val health = synchronized(micHealthLock) {
815
+ decodeFailures += 1
816
+ micHealthSnapshotLocked()
817
+ }
818
+ Bridge.sendMicHealth(health, "decode_failure")
819
+ }
820
+
821
+ private fun recordMicPcmProduced() {
822
+ synchronized(micHealthLock) { lastPcmProducedAt = System.currentTimeMillis() }
746
823
  }
747
824
 
748
825
  /**
@@ -750,12 +827,14 @@ class DeviceManager {
750
827
  * canonical LC3 encoding. Note: frameSize here is for glasses→phone decoding, NOT for
751
828
  * phone→cloud encoding.
752
829
  */
753
- fun handleGlassesMicData(rawLC3Data: ByteArray, frameSize: Int = 40) {
754
- lastLc3Event = System.currentTimeMillis()
830
+ @JvmOverloads
831
+ fun handleGlassesMicData(rawLC3Data: ByteArray, frameSize: Int = 40, sequenceNumber: Int? = null) {
832
+ recordLc3Packet(sequenceNumber)
755
833
  val pcmData: ByteArray?
756
834
  synchronized(lc3Lock) {
757
835
  if (lc3DecoderPtr == 0L) {
758
836
  Bridge.log("MAN: LC3 decoder not initialized, cannot process glasses audio")
837
+ recordMicDecodeFailure()
759
838
  return
760
839
  }
761
840
 
@@ -764,14 +843,17 @@ class DeviceManager {
764
843
  pcmData = Lc3Cpp.decodeLC3(lc3DecoderPtr, rawLC3Data, frameSize)
765
844
  } catch (e: Exception) {
766
845
  Bridge.log("MAN: Failed to decode glasses LC3: ${e.message}")
846
+ recordMicDecodeFailure()
767
847
  return
768
848
  }
769
849
  }
770
850
  if (pcmData != null && pcmData.isNotEmpty()) {
771
851
  // Re-encode to canonical LC3 via handlePcm (outside lock to avoid deadlock)
852
+ recordMicPcmProduced()
772
853
  handlePcm(pcmData)
773
854
  } else {
774
855
  Bridge.log("MAN: LC3 decode returned empty data")
856
+ recordMicDecodeFailure()
775
857
  }
776
858
  }
777
859
 
@@ -1148,7 +1230,7 @@ class DeviceManager {
1148
1230
  Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
1149
1231
  sgc?.cleanup()
1150
1232
  sgc = null
1151
- lastSystemTimeSyncConnectionKey = ""
1233
+ resetSystemTimeSync()
1152
1234
  }
1153
1235
 
1154
1236
  if (sgc != null) {
@@ -1271,6 +1353,16 @@ class DeviceManager {
1271
1353
  return
1272
1354
  }
1273
1355
 
1356
+ if (pendingDeviceName.isNotEmpty()) {
1357
+ deviceName = pendingDeviceName
1358
+ }
1359
+ if (pendingDeviceAddress.isNotEmpty()) {
1360
+ deviceAddress = pendingDeviceAddress
1361
+ }
1362
+ pendingDeviceName = ""
1363
+ pendingDeviceAddress = ""
1364
+ pendingWearable = ""
1365
+
1274
1366
  val readyKey = "${sgc?.type}:${deviceName}"
1275
1367
  val now = System.currentTimeMillis()
1276
1368
  if (readyKey == lastReadyHandledKey && now - lastReadyHandledAtMs < 2000) {
@@ -1281,7 +1373,7 @@ class DeviceManager {
1281
1373
  lastReadyHandledAtMs = now
1282
1374
 
1283
1375
  Bridge.log("MAN: handleDeviceReady() ${sgc?.type}")
1284
- pendingWearable = ""
1376
+ resetMicHealth()
1285
1377
  defaultWearable = sgc?.type ?: ""
1286
1378
  searching = false
1287
1379
 
@@ -1351,9 +1443,25 @@ class DeviceManager {
1351
1443
  }
1352
1444
 
1353
1445
  lastSystemTimeSyncConnectionKey = connectionKey
1354
- val timestampMs = System.currentTimeMillis()
1355
- Bridge.log("MAN: Syncing glasses system time once for connection: $timestampMs")
1356
- activeSgc.sendSetSystemTime(timestampMs)
1446
+ pendingSystemTimeSync?.let { mainHandler.removeCallbacks(it) }
1447
+ val sync =
1448
+ Runnable {
1449
+ pendingSystemTimeSync = null
1450
+ if (lastSystemTimeSyncConnectionKey != connectionKey || sgc !== activeSgc) {
1451
+ return@Runnable
1452
+ }
1453
+ val timestampMs = System.currentTimeMillis()
1454
+ Bridge.log("MAN: Syncing glasses system time once for connection: $timestampMs")
1455
+ activeSgc.sendSetSystemTime(timestampMs)
1456
+ }
1457
+ pendingSystemTimeSync = sync
1458
+ mainHandler.postDelayed(sync, 3000)
1459
+ }
1460
+
1461
+ private fun resetSystemTimeSync() {
1462
+ pendingSystemTimeSync?.let { mainHandler.removeCallbacks(it) }
1463
+ pendingSystemTimeSync = null
1464
+ lastSystemTimeSyncConnectionKey = ""
1357
1465
  }
1358
1466
 
1359
1467
  private fun handleG1Ready() {
@@ -1368,7 +1476,8 @@ class DeviceManager {
1368
1476
 
1369
1477
  fun handleDeviceDisconnected() {
1370
1478
  Bridge.log("MAN: Device disconnected")
1371
- lastSystemTimeSyncConnectionKey = ""
1479
+ resetSystemTimeSync()
1480
+ resetMicHealth()
1372
1481
  DeviceStore.apply("glasses", "headUp", false)
1373
1482
  DeviceStore.apply(
1374
1483
  "glasses",
@@ -1641,6 +1750,11 @@ class DeviceManager {
1641
1750
  sgc?.sendHotspotState(enabled)
1642
1751
  }
1643
1752
 
1753
+ fun setWifiAdbState(enabled: Boolean) {
1754
+ Bridge.log("MAN: Setting glasses Wi-Fi ADB state: $enabled")
1755
+ sgc?.sendWifiAdbState(enabled)
1756
+ }
1757
+
1644
1758
  fun setSystemTime(timestampMs: Long) {
1645
1759
  Bridge.log("MAN: Setting glasses system time: $timestampMs")
1646
1760
  sgc?.sendSetSystemTime(timestampMs)
@@ -1875,6 +1989,11 @@ class DeviceManager {
1875
1989
  )
1876
1990
  }
1877
1991
 
1992
+ fun queryVideoRecordingStatus(requestId: String) {
1993
+ Bridge.log("MAN: Querying video recording status: requestId=$requestId")
1994
+ sgc?.queryVideoRecordingStatus(requestId)
1995
+ }
1996
+
1878
1997
  fun stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
1879
1998
  Bridge.log(
1880
1999
  "MAN: onStopVideoRecording: requestId=$requestId, webhook=" +
@@ -1961,6 +2080,11 @@ class DeviceManager {
1961
2080
  return
1962
2081
  }
1963
2082
  initSGC(defaultWearable)
2083
+ val live = sgc as? MentraLive
2084
+ if (live?.isPairingYieldActive() == true) {
2085
+ Bridge.log("MAN: connectDefault skipped — Mentra Live pairing yield active")
2086
+ return
2087
+ }
1964
2088
  searching = true
1965
2089
  sgc?.connectById(reconnectTarget)
1966
2090
  connectDefaultController()
@@ -2016,10 +2140,10 @@ class DeviceManager {
2016
2140
  disconnect()
2017
2141
  Thread.sleep(100)
2018
2142
  searching = true
2019
- deviceName = name
2143
+ pendingDeviceName = name
2020
2144
 
2021
2145
  initSGC(pendingWearable)
2022
- sgc?.connectById(deviceName)
2146
+ sgc?.connectById(name)
2023
2147
  }
2024
2148
 
2025
2149
  fun connectDevice(deviceModel: String, deviceName: String) {
@@ -2050,7 +2174,8 @@ class DeviceManager {
2050
2174
  sgc?.clearDisplay()
2051
2175
  sgc?.disconnect()
2052
2176
  sgc = null // Clear the SGC reference after disconnect
2053
- lastSystemTimeSyncConnectionKey = ""
2177
+ resetSystemTimeSync()
2178
+ resetMicHealth()
2054
2179
  searching = false
2055
2180
  micEnabled = false
2056
2181
  updateMicState()
@@ -2061,6 +2186,7 @@ class DeviceManager {
2061
2186
  // previously connected pair can never be reported for the next connection.
2062
2187
  DeviceStore.apply("glasses", "serialNumber", "")
2063
2188
  DeviceStore.apply("glasses", "bluetoothMacAddress", "")
2189
+ DeviceStore.apply("glasses", "wifiMacAddress", "")
2064
2190
  DeviceStore.apply("glasses", "leftMacAddress", "")
2065
2191
  DeviceStore.apply("glasses", "rightMacAddress", "")
2066
2192
  DeviceStore.apply("glasses", "macAddress", "")
@@ -2089,20 +2215,75 @@ class DeviceManager {
2089
2215
  fun forget() {
2090
2216
  Bridge.log("MAN: Forgetting smart glasses")
2091
2217
 
2092
- // Call forget first to stop timers/handlers/reconnect logic
2093
- sgc?.forget()
2094
-
2095
- // Then disconnect to close connections
2096
- disconnect()
2218
+ val live = sgc as? MentraLive
2219
+ if (live != null) {
2220
+ // MentraLive.forget() disconnects GATT then removeBond. Do not refuse
2221
+ // during CTKD bonding — Unpair is an explicit user request to drop the pair.
2222
+ live.forget()
2223
+ // MentraLive.forget() already destroyed the SGC. Clear the manager reference and
2224
+ // session state without calling disconnect() again (that would hit a dead instance
2225
+ // and leave a destroyed MentraLive retained for the next scan).
2226
+ sgc = null
2227
+ resetSystemTimeSync()
2228
+ searching = false
2229
+ micEnabled = false
2230
+ updateMicState()
2231
+ shouldSendBootingMessage = true
2232
+ DeviceStore.apply("glasses", "deviceModel", "")
2233
+ DeviceStore.apply("glasses", "serialNumber", "")
2234
+ DeviceStore.apply("glasses", "bluetoothMacAddress", "")
2235
+ DeviceStore.apply("glasses", "leftMacAddress", "")
2236
+ DeviceStore.apply("glasses", "rightMacAddress", "")
2237
+ DeviceStore.apply("glasses", "macAddress", "")
2238
+ DeviceStore.apply("glasses", "fullyBooted", false)
2239
+ DeviceStore.apply("glasses", "connected", false)
2240
+ DeviceStore.apply(
2241
+ "glasses",
2242
+ "voiceActivityDetectionEnabled",
2243
+ BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED,
2244
+ )
2245
+ searchingController = false
2246
+ DeviceStore.apply("glasses", "controllerConnected", false)
2247
+ controller?.disconnect()
2248
+ controller = null
2249
+ } else {
2250
+ // Typical abandonAttempt path: disconnect() already destroyed SGC and nulled
2251
+ // this.sgc, so MentraLive.forget() never ran and Classic bonds stayed up —
2252
+ // glasses keep IBRT ACL and stop BLE advertising. Only tear Live bonds when
2253
+ // the selected/pending wearable is Mentra Live — forgetting G1/G2 must not
2254
+ // unpair an unrelated Live unit.
2255
+ val liveTarget =
2256
+ defaultWearable == DeviceTypes.LIVE || pendingWearable == DeviceTypes.LIVE
2257
+ if (liveTarget) {
2258
+ val extraAddress =
2259
+ when {
2260
+ defaultWearable == DeviceTypes.LIVE ->
2261
+ deviceAddress.takeIf { it.isNotEmpty() }
2262
+ pendingWearable == DeviceTypes.LIVE ->
2263
+ pendingDeviceAddress.takeIf { it.isNotEmpty() }
2264
+ else -> null
2265
+ }
2266
+ MentraLive.unbondBondedMentraLiveDevices(
2267
+ Bridge.getContext(),
2268
+ extraAddress,
2269
+ )
2270
+ }
2271
+ sgc?.forget()
2272
+ disconnect()
2273
+ }
2097
2274
 
2098
2275
  // Clear state
2099
2276
  defaultWearable = ""
2100
2277
  deviceName = ""
2101
2278
  deviceAddress = ""
2279
+ pendingDeviceName = ""
2280
+ pendingDeviceAddress = ""
2281
+ pendingWearable = ""
2102
2282
  Bridge.saveSetting("default_wearable", "")
2103
2283
  Bridge.saveSetting("device_name", "")
2104
2284
  Bridge.saveSetting("device_address", "")
2105
2285
  Bridge.saveSetting("project_name", "")
2286
+ Bridge.saveSetting("pending_wearable", "")
2106
2287
  }
2107
2288
 
2108
2289
  fun forgetController() {
@@ -65,6 +65,7 @@ object DeviceStore {
65
65
  store.set("glasses", "connectionState", ConnTypes.DISCONNECTED)
66
66
  store.set("glasses", "deviceModel", "")
67
67
  store.set("glasses", "firmwareVersion", "")
68
+ store.set("glasses", "hotspotOtaVersion", 0)
68
69
  store.set("glasses", "micEnabled", false)
69
70
  store.set("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
70
71
  store.set("glasses", "bluetoothClassicConnected", false)
@@ -74,6 +75,7 @@ object DeviceStore {
74
75
  store.set("glasses", "caseBatteryLevel", -1)
75
76
  store.set("glasses", "headUp", false)
76
77
  store.set("glasses", "bluetoothMacAddress", "")
78
+ store.set("glasses", "wifiMacAddress", "")
77
79
  store.set("glasses", "leftMacAddress", "")
78
80
  store.set("glasses", "rightMacAddress", "")
79
81
  store.set("glasses", "macAddress", "")
@@ -0,0 +1,11 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ /** Generated by release CI. Do not edit in a release checkout. */
4
+ internal object GeneratedReleaseMetadata {
5
+ const val FAMILY_BASE_VERSION: String = "3.1.0"
6
+ const val RELEASE_IDENTITY: String = "3.1.0-dev.7"
7
+ const val RELEASE_SET_ID: String = "mentra-3.1.0-dev.7"
8
+ const val SOURCE_COMMIT: String = "872697a6395c31d30ec75d64f039eaa763dcbb51"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-v3.1.0-dev.7/mentra-live-ota-3.1.0-dev.7.json"
10
+ const val OTA_MANIFEST_SHA256: String = "8fbea7589345658dc5dffcb394229ffc5c52fbe75352acb8f6827d83290ce750"
11
+ }
@@ -1,7 +1,10 @@
1
1
  package com.mentra.bluetoothsdk
2
2
 
3
+ import android.app.Activity
4
+ import android.app.Application
3
5
  import android.bluetooth.BluetoothManager
4
6
  import android.content.Context
7
+ import android.os.Bundle
5
8
  import android.os.Handler
6
9
  import android.os.Looper
7
10
  import com.mentra.bluetoothsdk.utils.ControllerTypes
@@ -30,6 +33,23 @@ class MentraBluetoothSdk private constructor(
30
33
  private val discoveredDeviceNames = mutableSetOf<String>()
31
34
  private val bridgeEventSinkId: String
32
35
  private val storeListenerId: String
36
+ private val activityLifecycleCallbacks =
37
+ object : Application.ActivityLifecycleCallbacks {
38
+ override fun onActivityResumed(activity: Activity) {
39
+ // Match the Mentra App's OnActivityEntersForeground hook. This runs after
40
+ // runtime permission dialogs in native host apps, allowing the service to
41
+ // replace its temporary dataSync type with connectedDevice/microphone/location.
42
+ deviceManager.refreshForegroundServiceTypes()
43
+ }
44
+
45
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
46
+ override fun onActivityStarted(activity: Activity) = Unit
47
+ override fun onActivityPaused(activity: Activity) = Unit
48
+ override fun onActivityStopped(activity: Activity) = Unit
49
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
50
+ override fun onActivityDestroyed(activity: Activity) = Unit
51
+ }
52
+ private var activityLifecycleCallbacksRegistered = false
33
53
  private var suppressDefaultDeviceEvents = false
34
54
  private val streamKeepAliveLock = Any()
35
55
  private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
@@ -57,6 +77,10 @@ class MentraBluetoothSdk private constructor(
57
77
  listeners.add(listener)
58
78
  Bridge.initialize(appContext)
59
79
  deviceManager = DeviceManager.getInstance()
80
+ (appContext as? Application)?.let { application ->
81
+ application.registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
82
+ activityLifecycleCallbacksRegistered = true
83
+ }
60
84
  bridgeEventSinkId = Bridge.addEventSink { eventName, data -> dispatchBridgeEvent(eventName, data) }
61
85
  // Baseline the analytics connection state before subscribing to the store:
62
86
  // store updates invoke listeners synchronously on the updating thread, so a
@@ -258,6 +282,8 @@ class MentraBluetoothSdk private constructor(
258
282
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", device.name)
259
283
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", device.address ?: "")
260
284
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", device.projectName ?: "")
285
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
286
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
261
287
  } finally {
262
288
  suppressDefaultDeviceEvents = false
263
289
  }
@@ -271,6 +297,8 @@ class MentraBluetoothSdk private constructor(
271
297
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", "")
272
298
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", "")
273
299
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", "")
300
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
301
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
274
302
  } finally {
275
303
  suppressDefaultDeviceEvents = false
276
304
  }
@@ -383,8 +411,24 @@ class MentraBluetoothSdk private constructor(
383
411
  cancelConnectionAttempt()
384
412
  }
385
413
  }
386
- if (options.saveAsDefault && !isController) {
387
- setDefaultDevice(device)
414
+ if (!isController) {
415
+ if (options.saveAsDefault) {
416
+ setDefaultDevice(device)
417
+ } else {
418
+ // Pairing uses saveAsDefault=false so default identity stays on the
419
+ // previous owner until handleDeviceReady. Stash the GATT target separately
420
+ // so MentraLive can connect by MAC without promoting getDefaultDevice().
421
+ DeviceStore.apply(
422
+ ObservableStore.BLUETOOTH_CATEGORY,
423
+ "pending_device_name",
424
+ device.name,
425
+ )
426
+ DeviceStore.apply(
427
+ ObservableStore.BLUETOOTH_CATEGORY,
428
+ "pending_device_address",
429
+ device.address ?: "",
430
+ )
431
+ }
388
432
  }
389
433
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_wearable", device.model.deviceType)
390
434
  deviceManager.connectByName(device.name)
@@ -408,6 +452,8 @@ class MentraBluetoothSdk private constructor(
408
452
  }
409
453
 
410
454
  fun cancelConnectionAttempt() {
455
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
456
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
411
457
  deviceManager.disconnect()
412
458
  }
413
459
 
@@ -416,6 +462,8 @@ class MentraBluetoothSdk private constructor(
416
462
  }
417
463
 
418
464
  fun disconnect() {
465
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
466
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
419
467
  deviceManager.disconnect()
420
468
  }
421
469
 
@@ -856,6 +904,10 @@ class MentraBluetoothSdk private constructor(
856
904
  }
857
905
  }
858
906
 
907
+ fun setWifiAdbState(enabled: Boolean) {
908
+ deviceManager.setWifiAdbState(enabled)
909
+ }
910
+
859
911
  fun setSystemTime(timestampMs: Long) {
860
912
  deviceManager.setSystemTime(timestampMs)
861
913
  }
@@ -1087,6 +1139,25 @@ class MentraBluetoothSdk private constructor(
1087
1139
  }
1088
1140
  }
1089
1141
 
1142
+ suspend fun queryVideoRecordingStatus(requestId: String): VideoRecordingStatusEvent {
1143
+ require(requestId.isNotBlank()) { "requestId is required to query video recording status." }
1144
+ requireGlassesConnected("query video recording status")
1145
+ val pending = PendingResponse<VideoRecordingStatusEvent>("query video recording status")
1146
+ val pendingRequest = PendingVideoRecordingRequest("recording_status", pending)
1147
+ if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
1148
+ throw BluetoothSdkException(
1149
+ "request_in_flight",
1150
+ "A video recording command is already waiting for requestId $requestId.",
1151
+ )
1152
+ }
1153
+ try {
1154
+ deviceManager.queryVideoRecordingStatus(requestId)
1155
+ return pending.await()
1156
+ } finally {
1157
+ pendingVideoRecordingRequests.remove(requestId, pendingRequest)
1158
+ }
1159
+ }
1160
+
1090
1161
  suspend fun requestVersionInfo(): VersionInfoResult {
1091
1162
  val pending = PendingResponse<VersionInfoResult>("version info request")
1092
1163
  synchronized(oneShotLock) {
@@ -1110,11 +1181,13 @@ class MentraBluetoothSdk private constructor(
1110
1181
  }
1111
1182
  }
1112
1183
 
1113
- internal fun setOtaVersionUrl(otaVersionUrl: String) {
1184
+ /** Select the HTTP(S) manifest used by subsequent OTA checks and installs. */
1185
+ fun setOtaVersionUrl(otaVersionUrl: String) {
1114
1186
  configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
1115
1187
  }
1116
1188
 
1117
- internal fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1189
+ /** Return the configured OTA manifest, or this SDK release's default manifest. */
1190
+ fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1118
1191
 
1119
1192
  /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
1120
1193
  suspend fun checkForOtaUpdate(): Boolean {
@@ -1240,6 +1313,12 @@ class MentraBluetoothSdk private constructor(
1240
1313
  systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
1241
1314
  otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1242
1315
  appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1316
+ hotspotOtaVersion =
1317
+ if (versionInfo.hotspotOtaVersion > 0) {
1318
+ versionInfo.hotspotOtaVersion
1319
+ } else {
1320
+ status.hotspotOtaVersion
1321
+ },
1243
1322
  )
1244
1323
  } catch (cancellation: CancellationException) {
1245
1324
  // Never swallow cancellation into the stale-status fallback: callers
@@ -1323,6 +1402,12 @@ class MentraBluetoothSdk private constructor(
1323
1402
 
1324
1403
  override fun close() {
1325
1404
  stopStreamKeepAliveMonitor()
1405
+ if (activityLifecycleCallbacksRegistered) {
1406
+ (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
1407
+ activityLifecycleCallbacks,
1408
+ )
1409
+ activityLifecycleCallbacksRegistered = false
1410
+ }
1326
1411
  Bridge.removeEventSink(bridgeEventSinkId)
1327
1412
  DeviceStore.store.removeListener(storeListenerId)
1328
1413
  analytics.shutdown()
@@ -1520,6 +1605,9 @@ class MentraBluetoothSdk private constructor(
1520
1605
  }
1521
1606
  dispatchToListeners { it.onGalleryStatus(event) }
1522
1607
  }
1608
+ "pairing_info" -> {
1609
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1610
+ }
1523
1611
  "photo_response" -> {
1524
1612
  val event = PhotoResponseEvent(data)
1525
1613
  handlePhotoResponseForRequests(event)
@@ -1567,10 +1655,19 @@ class MentraBluetoothSdk private constructor(
1567
1655
  }
1568
1656
  "ota_status" -> {
1569
1657
  val resultValues = data + mapOf("type" to "ota_status")
1658
+ val event = OtaStatusEvent.fromMap(resultValues)
1570
1659
  synchronized(oneShotLock) {
1571
1660
  pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1661
+ otaStartRejectionErrorCode(event)?.let { errorCode ->
1662
+ pendingOtaStart?.reject(
1663
+ BluetoothSdkException(errorCode, "Glasses rejected OTA start: $errorCode")
1664
+ )
1665
+ }
1572
1666
  }
1573
- dispatchToListeners { it.onOtaStatus(OtaStatusEvent.fromMap(resultValues)) }
1667
+ dispatchToListeners { it.onOtaStatus(event) }
1668
+ }
1669
+ "mic_health" -> {
1670
+ dispatchToListeners { it.onMicHealth(MicHealthEvent.fromMap(data)) }
1574
1671
  }
1575
1672
  "settings_ack" -> {
1576
1673
  val event = SettingsAckEvent(data)
@@ -2161,6 +2258,13 @@ class MentraBluetoothSdk private constructor(
2161
2258
  }
2162
2259
  }
2163
2260
 
2164
-
2165
-
2166
-
2261
+ /** OTA status messages are not request-correlated, so only known pre-ack failures settle a start. */
2262
+ internal fun otaStartRejectionErrorCode(event: OtaStatusEvent): String? =
2263
+ if (
2264
+ event.status.equals("failed", ignoreCase = true) &&
2265
+ event.errorMessage.equals("battery_low", ignoreCase = true)
2266
+ ) {
2267
+ "battery_low"
2268
+ } else {
2269
+ null
2270
+ }