@glassly/bluetooth-sdk 0.1.0-dev.26

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 (548) hide show
  1. package/README.md +563 -0
  2. package/android/build.gradle +450 -0
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/CMakeLists.txt +6 -0
  5. package/android/lc3Lib/build.gradle +212 -0
  6. package/android/lc3Lib/proguard-rules.pro +33 -0
  7. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  8. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  26. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  81. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  82. package/android/lc3Lib/src/main/java/com/glassly/lc3Lib/Lc3Cpp.java +38 -0
  83. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  86. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  119. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  120. package/android/settings.gradle +4 -0
  121. package/android/silero/LICENSE.md +17 -0
  122. package/android/silero/build.gradle +50 -0
  123. package/android/silero/proguard-rules.pro +24 -0
  124. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  125. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  126. package/android/silero/src/main/assets/LICENSE +21 -0
  127. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  128. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  129. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  130. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  131. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  132. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  133. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  134. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  135. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  136. package/android/src/main/AndroidManifest.xml +42 -0
  137. package/android/src/main/assets/G2Firmware/README.md +11 -0
  138. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +2325 -0
  139. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkAnalytics.kt +216 -0
  140. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDebug.kt +14 -0
  141. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDefaults.kt +7 -0
  142. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +1088 -0
  143. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +953 -0
  144. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +2528 -0
  145. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +360 -0
  146. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +8 -0
  147. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  148. package/android/src/main/java/com/glassly/bluetoothsdk/LocalNetworkModule.kt +414 -0
  149. package/android/src/main/java/com/glassly/bluetoothsdk/ObservableStore.kt +88 -0
  150. package/android/src/main/java/com/glassly/bluetoothsdk/OtaManifest.kt +191 -0
  151. package/android/src/main/java/com/glassly/bluetoothsdk/ReleaseChangelog.kt +61 -0
  152. package/android/src/main/java/com/glassly/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  153. package/android/src/main/java/com/glassly/bluetoothsdk/audio/AudioModels.kt +144 -0
  154. package/android/src/main/java/com/glassly/bluetoothsdk/camera/CameraModels.kt +486 -0
  155. package/android/src/main/java/com/glassly/bluetoothsdk/connection/ConnectionModels.kt +78 -0
  156. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  157. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +937 -0
  158. package/android/src/main/java/com/glassly/bluetoothsdk/debug/BleTraceLogger.kt +165 -0
  159. package/android/src/main/java/com/glassly/bluetoothsdk/events/BluetoothEvents.kt +178 -0
  160. package/android/src/main/java/com/glassly/bluetoothsdk/internal/MapParsing.kt +140 -0
  161. package/android/src/main/java/com/glassly/bluetoothsdk/net/LocalIpv4.kt +57 -0
  162. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  163. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/OtaServerModule.kt +120 -0
  164. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  165. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +622 -0
  166. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/PhotoReceiverModule.kt +156 -0
  167. package/android/src/main/java/com/glassly/bluetoothsdk/requests/DisplayRequests.kt +56 -0
  168. package/android/src/main/java/com/glassly/bluetoothsdk/services/Foreground.kt +272 -0
  169. package/android/src/main/java/com/glassly/bluetoothsdk/services/PhoneMic.kt +1195 -0
  170. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Ar99.java +2653 -0
  171. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  172. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  173. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1.kt +3878 -0
  174. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  175. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +6428 -0
  176. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +49 -0
  177. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +1241 -0
  178. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +270 -0
  179. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +291 -0
  180. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +411 -0
  181. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/GlasslyBle.java +43189 -0
  182. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Mach1.kt +1280 -0
  183. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +10817 -0
  184. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  185. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  186. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +2011 -0
  187. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  188. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +470 -0
  189. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Simulated.kt +228 -0
  190. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  191. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  192. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  193. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  194. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  195. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  196. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +525 -0
  197. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +669 -0
  198. package/android/src/main/java/com/glassly/bluetoothsdk/status/RuntimeState.kt +201 -0
  199. package/android/src/main/java/com/glassly/bluetoothsdk/status/WifiHotspotStatus.kt +189 -0
  200. package/android/src/main/java/com/glassly/bluetoothsdk/streaming/StreamModels.kt +518 -0
  201. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +391 -0
  202. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +518 -0
  203. package/android/src/main/java/com/glassly/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  204. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +248 -0
  205. package/android/src/main/java/com/glassly/bluetoothsdk/types/DeviceModels.kt +96 -0
  206. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AES.java +106 -0
  207. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  208. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  209. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  210. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleJsonCompact.java +496 -0
  211. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadService.java +677 -0
  212. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  213. package/android/src/main/java/com/glassly/bluetoothsdk/utils/Constants.kt +46 -0
  214. package/android/src/main/java/com/glassly/bluetoothsdk/utils/G1Text.kt +436 -0
  215. package/android/src/main/java/com/glassly/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  216. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  217. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleUploadService.java +77 -0
  218. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  219. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtils.java +827 -0
  220. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunkReassembler.java +228 -0
  221. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunker.java +325 -0
  222. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +698 -0
  223. package/android/src/main/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitor.kt +348 -0
  224. package/android/src/main/java/com/glassly/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  225. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  226. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  227. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  228. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  229. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  230. package/android/src/main/res/values/strings.xml +4 -0
  231. package/android/src/test/java/com/glassly/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  232. package/android/src/test/java/com/glassly/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  233. package/android/src/test/java/com/glassly/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  234. package/android/src/test/java/com/glassly/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  235. package/android/src/test/java/com/glassly/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  236. package/android/src/test/java/com/glassly/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  237. package/android/src/test/java/com/glassly/bluetoothsdk/camera/PhotoRequestTest.kt +141 -0
  238. package/android/src/test/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  239. package/android/src/test/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  240. package/android/src/test/java/com/glassly/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  241. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  242. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  243. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  244. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/EvenRleTest.kt +63 -0
  245. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  246. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +89 -0
  247. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +344 -0
  248. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +200 -0
  249. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocolTest.kt +131 -0
  250. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  251. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  252. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  253. package/android/src/test/java/com/glassly/bluetoothsdk/utils/AvifExifStripperTest.java +46 -0
  254. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  255. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BleJsonCompactTest.java +319 -0
  256. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +107 -0
  257. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  258. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  259. package/android/src/test/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  260. package/android/src/test/resources/avif_with_exif.avif +0 -0
  261. package/app.plugin.js +3 -0
  262. package/build/BluetoothSdk.types.d.ts +1354 -0
  263. package/build/BluetoothSdk.types.d.ts.map +1 -0
  264. package/build/BluetoothSdk.types.js +41 -0
  265. package/build/BluetoothSdk.types.js.map +1 -0
  266. package/build/_internal.d.ts +17 -0
  267. package/build/_internal.d.ts.map +1 -0
  268. package/build/_internal.js +16 -0
  269. package/build/_internal.js.map +1 -0
  270. package/build/_private/BluetoothSdkModule.d.ts +172 -0
  271. package/build/_private/BluetoothSdkModule.d.ts.map +1 -0
  272. package/build/_private/BluetoothSdkModule.js +300 -0
  273. package/build/_private/BluetoothSdkModule.js.map +1 -0
  274. package/build/_private/LocalNetworkModule.d.ts +38 -0
  275. package/build/_private/LocalNetworkModule.d.ts.map +1 -0
  276. package/build/_private/LocalNetworkModule.js +3 -0
  277. package/build/_private/LocalNetworkModule.js.map +1 -0
  278. package/build/_private/cameraRequestPayload.d.ts +4 -0
  279. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  280. package/build/_private/cameraRequestPayload.js +32 -0
  281. package/build/_private/cameraRequestPayload.js.map +1 -0
  282. package/build/_private/photoRequestPayload.d.ts +6 -0
  283. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  284. package/build/_private/photoRequestPayload.js +86 -0
  285. package/build/_private/photoRequestPayload.js.map +1 -0
  286. package/build/_private/sdkOtaManifest.d.ts +11 -0
  287. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  288. package/build/_private/sdkOtaManifest.js +15 -0
  289. package/build/_private/sdkOtaManifest.js.map +1 -0
  290. package/build/changelogs.d.ts +7 -0
  291. package/build/changelogs.d.ts.map +1 -0
  292. package/build/changelogs.js +46 -0
  293. package/build/changelogs.js.map +1 -0
  294. package/build/debug.d.ts +3 -0
  295. package/build/debug.d.ts.map +1 -0
  296. package/build/debug.js +8 -0
  297. package/build/debug.js.map +1 -0
  298. package/build/generated/changelogCatalog.d.ts +9 -0
  299. package/build/generated/changelogCatalog.d.ts.map +1 -0
  300. package/build/generated/changelogCatalog.js +12 -0
  301. package/build/generated/changelogCatalog.js.map +1 -0
  302. package/build/generated/releaseMetadata.d.ts +12 -0
  303. package/build/generated/releaseMetadata.d.ts.map +1 -0
  304. package/build/generated/releaseMetadata.js +10 -0
  305. package/build/generated/releaseMetadata.js.map +1 -0
  306. package/build/index.d.ts +6 -0
  307. package/build/index.d.ts.map +1 -0
  308. package/build/index.js +171 -0
  309. package/build/index.js.map +1 -0
  310. package/build/ota-server/OtaServer.types.d.ts +14 -0
  311. package/build/ota-server/OtaServer.types.d.ts.map +1 -0
  312. package/build/ota-server/OtaServer.types.js +2 -0
  313. package/build/ota-server/OtaServer.types.js.map +1 -0
  314. package/build/ota-server/OtaServerModule.d.ts +21 -0
  315. package/build/ota-server/OtaServerModule.d.ts.map +1 -0
  316. package/build/ota-server/OtaServerModule.js +3 -0
  317. package/build/ota-server/OtaServerModule.js.map +1 -0
  318. package/build/ota-server/OtaServerModule.web.d.ts +10 -0
  319. package/build/ota-server/OtaServerModule.web.d.ts.map +1 -0
  320. package/build/ota-server/OtaServerModule.web.js +12 -0
  321. package/build/ota-server/OtaServerModule.web.js.map +1 -0
  322. package/build/ota-server/index.d.ts +3 -0
  323. package/build/ota-server/index.d.ts.map +1 -0
  324. package/build/ota-server/index.js +3 -0
  325. package/build/ota-server/index.js.map +1 -0
  326. package/build/ota-transport/index.d.ts +67 -0
  327. package/build/ota-transport/index.d.ts.map +1 -0
  328. package/build/ota-transport/index.js +58 -0
  329. package/build/ota-transport/index.js.map +1 -0
  330. package/build/photo-receiver/PhotoReceiver.types.d.ts +18 -0
  331. package/build/photo-receiver/PhotoReceiver.types.d.ts.map +1 -0
  332. package/build/photo-receiver/PhotoReceiver.types.js +2 -0
  333. package/build/photo-receiver/PhotoReceiver.types.js.map +1 -0
  334. package/build/photo-receiver/PhotoReceiverModule.d.ts +10 -0
  335. package/build/photo-receiver/PhotoReceiverModule.d.ts.map +1 -0
  336. package/build/photo-receiver/PhotoReceiverModule.js +3 -0
  337. package/build/photo-receiver/PhotoReceiverModule.js.map +1 -0
  338. package/build/photo-receiver/PhotoReceiverModule.web.d.ts +10 -0
  339. package/build/photo-receiver/PhotoReceiverModule.web.d.ts.map +1 -0
  340. package/build/photo-receiver/PhotoReceiverModule.web.js +12 -0
  341. package/build/photo-receiver/PhotoReceiverModule.web.js.map +1 -0
  342. package/build/photo-receiver/index.d.ts +3 -0
  343. package/build/photo-receiver/index.d.ts.map +1 -0
  344. package/build/photo-receiver/index.js +3 -0
  345. package/build/photo-receiver/index.js.map +1 -0
  346. package/build/react/index.d.ts +5 -0
  347. package/build/react/index.d.ts.map +1 -0
  348. package/build/react/index.js +4 -0
  349. package/build/react/index.js.map +1 -0
  350. package/build/react/useBluetooth.d.ts +100 -0
  351. package/build/react/useBluetooth.d.ts.map +1 -0
  352. package/build/react/useBluetooth.js +167 -0
  353. package/build/react/useBluetooth.js.map +1 -0
  354. package/build/react/useBluetoothEvent.d.ts +6 -0
  355. package/build/react/useBluetoothEvent.d.ts.map +1 -0
  356. package/build/react/useBluetoothEvent.js +21 -0
  357. package/build/react/useBluetoothEvent.js.map +1 -0
  358. package/build/react/useBluetoothScan.d.ts +22 -0
  359. package/build/react/useBluetoothScan.d.ts.map +1 -0
  360. package/build/react/useBluetoothScan.js +135 -0
  361. package/build/react/useBluetoothScan.js.map +1 -0
  362. package/build/react/useBluetoothStatus.d.ts +16 -0
  363. package/build/react/useBluetoothStatus.d.ts.map +1 -0
  364. package/build/react/useBluetoothStatus.js +137 -0
  365. package/build/react/useBluetoothStatus.js.map +1 -0
  366. package/build/react/useGlassesConnection.d.ts +29 -0
  367. package/build/react/useGlassesConnection.d.ts.map +1 -0
  368. package/build/react/useGlassesConnection.js +153 -0
  369. package/build/react/useGlassesConnection.js.map +1 -0
  370. package/build/types/index.d.ts +3 -0
  371. package/build/types/index.d.ts.map +1 -0
  372. package/build/types/index.js +2 -0
  373. package/build/types/index.js.map +1 -0
  374. package/expo-module.config.json +14 -0
  375. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  376. package/ios/BluetoothSdkModule.swift +1034 -0
  377. package/ios/GlasslyBluetoothSDK.podspec +106 -0
  378. package/ios/LocalIPv4.swift +150 -0
  379. package/ios/LocalOtaServer.swift +309 -0
  380. package/ios/LocalPhotoUploadServer.swift +754 -0
  381. package/ios/OtaServerModule.swift +147 -0
  382. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  383. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  384. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  385. package/ios/Packages/CoreObjC/attdet.c +92 -0
  386. package/ios/Packages/CoreObjC/attdet.h +44 -0
  387. package/ios/Packages/CoreObjC/bits.c +375 -0
  388. package/ios/Packages/CoreObjC/bits.h +315 -0
  389. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  390. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  391. package/ios/Packages/CoreObjC/common.h +151 -0
  392. package/ios/Packages/CoreObjC/energy.c +70 -0
  393. package/ios/Packages/CoreObjC/energy.h +43 -0
  394. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  395. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  396. package/ios/Packages/CoreObjC/lc3.c +704 -0
  397. package/ios/Packages/CoreObjC/lc3.h +313 -0
  398. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  399. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  400. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  401. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  402. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  403. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  404. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  405. package/ios/Packages/CoreObjC/mdct.c +469 -0
  406. package/ios/Packages/CoreObjC/mdct.h +57 -0
  407. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  408. package/ios/Packages/CoreObjC/meson.build +61 -0
  409. package/ios/Packages/CoreObjC/plc.c +61 -0
  410. package/ios/Packages/CoreObjC/plc.h +57 -0
  411. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  412. package/ios/Packages/CoreObjC/sns.c +880 -0
  413. package/ios/Packages/CoreObjC/sns.h +103 -0
  414. package/ios/Packages/CoreObjC/spec.c +907 -0
  415. package/ios/Packages/CoreObjC/spec.h +119 -0
  416. package/ios/Packages/CoreObjC/tables.c +3457 -0
  417. package/ios/Packages/CoreObjC/tables.h +94 -0
  418. package/ios/Packages/CoreObjC/tns.c +457 -0
  419. package/ios/Packages/CoreObjC/tns.h +99 -0
  420. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  421. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  422. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +2383 -0
  423. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +48 -0
  424. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  425. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  426. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  427. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  428. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  429. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  430. package/ios/Packages/libbz2/module.modulemap +5 -0
  431. package/ios/Packages/libbz2/shim.h +1 -0
  432. package/ios/PhotoReceiverModule.swift +129 -0
  433. package/ios/Source/BluetoothSDK.swift +2303 -0
  434. package/ios/Source/BluetoothSDKDebug.swift +12 -0
  435. package/ios/Source/BluetoothSdkDefaults.swift +29 -0
  436. package/ios/Source/Bridge.swift +708 -0
  437. package/ios/Source/Bridging-Header.h +16 -0
  438. package/ios/Source/DeviceManager.swift +2109 -0
  439. package/ios/Source/DeviceStore.swift +336 -0
  440. package/ios/Source/GeneratedChangelogCatalog.swift +7 -0
  441. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  442. package/ios/Source/ObservableStore.swift +118 -0
  443. package/ios/Source/OtaManifest.swift +211 -0
  444. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  445. package/ios/Source/ReleaseChangelog.swift +65 -0
  446. package/ios/Source/Resources/G2Firmware/README.md +10 -0
  447. package/ios/Source/audio/AudioModels.swift +178 -0
  448. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  449. package/ios/Source/camera/CameraModels.swift +866 -0
  450. package/ios/Source/connection/ScanSession.swift +27 -0
  451. package/ios/Source/controllers/ControllerManager.swift +184 -0
  452. package/ios/Source/controllers/R1.swift +829 -0
  453. package/ios/Source/errors/BluetoothSdkError.swift +19 -0
  454. package/ios/Source/events/BluetoothEvents.swift +342 -0
  455. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  456. package/ios/Source/internal/BluetoothAvailability.swift +78 -0
  457. package/ios/Source/internal/BluetoothSdkAnalytics.swift +199 -0
  458. package/ios/Source/internal/ValueParsing.swift +99 -0
  459. package/ios/Source/requests/DisplayRequests.swift +82 -0
  460. package/ios/Source/services/PhoneMic.swift +662 -0
  461. package/ios/Source/sgcs/Ar99.swift +2451 -0
  462. package/ios/Source/sgcs/Frame.swift +649 -0
  463. package/ios/Source/sgcs/G1.swift +2554 -0
  464. package/ios/Source/sgcs/G2.swift +6887 -0
  465. package/ios/Source/sgcs/G2CfwClassification.swift +43 -0
  466. package/ios/Source/sgcs/G2CfwRenderer.swift +1279 -0
  467. package/ios/Source/sgcs/G2CfwTextureCache.swift +233 -0
  468. package/ios/Source/sgcs/G2FlashProtocol.swift +312 -0
  469. package/ios/Source/sgcs/G2Flasher.swift +773 -0
  470. package/ios/Source/sgcs/Mach1.swift +541 -0
  471. package/ios/Source/sgcs/MentraLive.swift +7011 -0
  472. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  473. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  474. package/ios/Source/sgcs/MentraNex.swift +3221 -0
  475. package/ios/Source/sgcs/Nimo.swift +1886 -0
  476. package/ios/Source/sgcs/SGCManager.swift +608 -0
  477. package/ios/Source/sgcs/Simulated.swift +274 -0
  478. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  479. package/ios/Source/sgcs/glassly_ble.pb.swift +4354 -0
  480. package/ios/Source/speakerid/SpeakerIdEngine.swift +573 -0
  481. package/ios/Source/status/DeviceStatus.swift +981 -0
  482. package/ios/Source/status/RuntimeState.swift +359 -0
  483. package/ios/Source/status/WifiHotspotStatus.swift +345 -0
  484. package/ios/Source/streaming/StreamModels.swift +693 -0
  485. package/ios/Source/stt/STTTools.swift +191 -0
  486. package/ios/Source/stt/SherpaOnnxTranscriber.swift +466 -0
  487. package/ios/Source/tts/TTSTools.swift +171 -0
  488. package/ios/Source/types/DeviceModels.swift +177 -0
  489. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  490. package/ios/Source/utils/BinaryCodec.swift +61 -0
  491. package/ios/Source/utils/BleJsonCompact.swift +405 -0
  492. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  493. package/ios/Source/utils/Constants.swift +78 -0
  494. package/ios/Source/utils/Enums.swift +95 -0
  495. package/ios/Source/utils/G1Text.swift +2096 -0
  496. package/ios/Source/utils/JSCExperiment.swift +241 -0
  497. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  498. package/ios/Source/utils/MessageChunkReassembler.swift +190 -0
  499. package/ios/Source/utils/MessageChunker.swift +294 -0
  500. package/ios/Source/utils/Models.swift +135 -0
  501. package/ios/Source/utils/PhoneAudioMonitor.swift +230 -0
  502. package/ios/Source/utils/TarBz2Extractor.swift +257 -0
  503. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +78 -0
  504. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +372 -0
  505. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +162 -0
  506. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +182 -0
  507. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  508. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  509. package/package.json +129 -0
  510. package/plugin/build/index.d.ts +10 -0
  511. package/plugin/build/index.js +12 -0
  512. package/plugin/build/withAndroid.d.ts +3 -0
  513. package/plugin/build/withAndroid.js +190 -0
  514. package/plugin/build/withIos.d.ts +5 -0
  515. package/plugin/build/withIos.js +108 -0
  516. package/scripts/inject-ios-sdk-version.mjs +65 -0
  517. package/scripts/public-ota-api.test.mjs +37 -0
  518. package/scripts/verify-release-package.mjs +45 -0
  519. package/scripts/write-release-metadata.mjs +152 -0
  520. package/scripts/write-release-metadata.test.mjs +37 -0
  521. package/src/BluetoothSdk.types.ts +1623 -0
  522. package/src/_internal.ts +16 -0
  523. package/src/_private/BluetoothSdkModule.ts +684 -0
  524. package/src/_private/LocalNetworkModule.ts +50 -0
  525. package/src/_private/cameraRequestPayload.ts +34 -0
  526. package/src/_private/photoRequestPayload.ts +90 -0
  527. package/src/_private/sdkOtaManifest.ts +18 -0
  528. package/src/changelogs.ts +46 -0
  529. package/src/debug.ts +9 -0
  530. package/src/generated/changelogCatalog.ts +13 -0
  531. package/src/generated/releaseMetadata.ts +20 -0
  532. package/src/index.ts +321 -0
  533. package/src/ota-server/OtaServer.types.ts +10 -0
  534. package/src/ota-server/OtaServerModule.ts +23 -0
  535. package/src/ota-server/OtaServerModule.web.ts +17 -0
  536. package/src/ota-server/index.ts +2 -0
  537. package/src/ota-transport/index.ts +116 -0
  538. package/src/photo-receiver/PhotoReceiver.types.ts +20 -0
  539. package/src/photo-receiver/PhotoReceiverModule.ts +11 -0
  540. package/src/photo-receiver/PhotoReceiverModule.web.ts +17 -0
  541. package/src/photo-receiver/index.ts +2 -0
  542. package/src/react/index.ts +21 -0
  543. package/src/react/useBluetooth.ts +309 -0
  544. package/src/react/useBluetoothEvent.ts +35 -0
  545. package/src/react/useBluetoothScan.ts +173 -0
  546. package/src/react/useBluetoothStatus.ts +180 -0
  547. package/src/react/useGlassesConnection.ts +202 -0
  548. package/src/types/index.ts +12 -0
@@ -0,0 +1,2325 @@
1
+ package com.glassly.bluetoothsdk
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.bluetooth.BluetoothManager
6
+ import android.content.Context
7
+ import android.os.Bundle
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import com.glassly.bluetoothsdk.utils.ControllerTypes
11
+ import com.glassly.bluetoothsdk.utils.PhoneAudioMonitor
12
+ import java.util.Collections
13
+ import java.util.UUID
14
+ import java.util.concurrent.ConcurrentHashMap
15
+ import java.util.concurrent.atomic.AtomicBoolean
16
+ import java.util.concurrent.atomic.AtomicLong
17
+ import kotlinx.coroutines.CancellationException
18
+ import kotlinx.coroutines.CompletableDeferred
19
+ import kotlinx.coroutines.delay
20
+ import kotlinx.coroutines.withTimeoutOrNull
21
+
22
+ class BluetoothSdk private constructor(
23
+ context: Context,
24
+ private val config: BluetoothSdkConfig,
25
+ listener: BluetoothSdkListener,
26
+ ) : AutoCloseable {
27
+ private val appContext = context.applicationContext
28
+ private val mainHandler = Handler(Looper.getMainLooper())
29
+ private val deviceManager: DeviceManager
30
+ private val listeners =
31
+ Collections.synchronizedSet(mutableSetOf<BluetoothSdkListener>())
32
+ private val analytics = BluetoothSdkAnalytics(appContext, config.analytics)
33
+ private val discoveredDeviceNames = mutableSetOf<String>()
34
+ private val bridgeEventSinkId: String
35
+ private val storeListenerId: String
36
+ private val activityLifecycleCallbacks =
37
+ object : Application.ActivityLifecycleCallbacks {
38
+ override fun onActivityResumed(activity: Activity) {
39
+ // Match the Glassly 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
53
+ private var suppressDefaultDeviceEvents = false
54
+ private val streamKeepAliveLock = Any()
55
+ private var activeStreamKeepAlive: ActiveStreamKeepAlive? = null
56
+ private val pendingPhotoRequests = ConcurrentHashMap<String, PendingResponse<PhotoResponseEvent>>()
57
+ private val pendingCameraStatusRequests = ConcurrentHashMap<String, PendingResponse<CameraStatusEvent>>()
58
+ private val pendingVideoRecordingRequests =
59
+ ConcurrentHashMap<String, PendingVideoRecordingRequest>()
60
+ private val pendingRgbLedRequests = ConcurrentHashMap<String, PendingResponse<RgbLedControlResponseEvent>>()
61
+ private val pendingSettingsRequests = ConcurrentHashMap<String, PendingResponse<SettingsAckEvent>>()
62
+ private val pendingStreamStarts = ConcurrentHashMap<String, PendingStreamStart>()
63
+ private val streamStartSeq = AtomicLong()
64
+ private val streamStartOrderLock = Any()
65
+ private val oneShotLock = Any()
66
+ private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>? = null
67
+ private var pendingOtaQuery: PendingResponse<OtaQueryResult>? = null
68
+ private var pendingOtaStart: PendingResponse<OtaStartAckEvent>? = null
69
+ private var pendingStreamStop: PendingStreamStop? = null
70
+ private var pendingWifiScan: PendingWifiScan? = null
71
+ private var pendingWifiStatus: PendingWifiStatusRequest? = null
72
+ private var pendingHotspotStatus: PendingHotspotStatusRequest? = null
73
+ private var pendingVersionInfo: PendingResponse<VersionInfoResult>? = null
74
+ @Volatile private var configuredOtaVersionUrl: String? = null
75
+
76
+ init {
77
+ listeners.add(listener)
78
+ Bridge.initialize(appContext)
79
+ deviceManager = DeviceManager.getInstance()
80
+ (appContext as? Application)?.let { application ->
81
+ application.registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
82
+ activityLifecycleCallbacksRegistered = true
83
+ }
84
+ bridgeEventSinkId = Bridge.addEventSink { eventName, data -> dispatchBridgeEvent(eventName, data) }
85
+ // Baseline the analytics connection state before subscribing to the store:
86
+ // store updates invoke listeners synchronously on the updating thread, so a
87
+ // connected status observed before the baseline would be reported as a fresh
88
+ // bluetooth_sdk_glasses_connected transition.
89
+ analytics.initializeGlassesStatus(getRawGlassesStatus())
90
+ storeListenerId = DeviceStore.store.addListener { category, changes -> dispatchStoreUpdate(category, changes) }
91
+ analytics.captureStarted()
92
+ }
93
+
94
+ companion object {
95
+ private val DEFAULT_DEVICE_KEYS = setOf("default_wearable", "device_name", "device_address", "project_name")
96
+ private val SCAN_STATE_KEYS = setOf("searching", "searchingController", "searchResults")
97
+ private const val DEFAULT_SCAN_TIMEOUT_MS = 15_000L
98
+ private const val DEFAULT_REQUEST_TIMEOUT_MS = 15_000L
99
+ // A photo response is terminal only after capture, encoding, transport, and upload.
100
+ // Max-quality BLE fallback can legitimately exceed the generic command deadline.
101
+ private const val PHOTO_REQUEST_TIMEOUT_MS = 30_000L
102
+ private const val WIFI_SCAN_TIMEOUT_MS = 20_000L
103
+ private const val VIDEO_UPLOAD_STOP_TIMEOUT_MS = 10 * 60 * 1000L
104
+ private const val STREAM_START_TIMEOUT_MS = 30_000L
105
+ private const val STREAM_STOP_TIMEOUT_MS = 15_000L
106
+ private const val OTA_BES_VERSION_WAIT_MS = 5_000L
107
+ private const val OTA_MTK_VERSION_WAIT_MS = 2_000L
108
+ private const val OTA_VERSION_POLL_MS = 100L
109
+ private const val DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS = 5
110
+ private const val MAX_MISSED_STREAM_KEEP_ALIVE_ACKS = 3
111
+
112
+ // Stream states the glasses only reach after start_stream has run past
113
+ // stopAllServices() — the point where every older start is actually
114
+ // preempted. ERROR and STOPPED are deliberately absent: an ERROR can be
115
+ // a command-level preflight rejection emitted before stopAllServices,
116
+ // and a STOPPED is a stop ack; neither proves older starts were killed.
117
+ private val PREEMPTION_PROVEN_STATES = setOf(
118
+ StreamState.INITIALIZING,
119
+ StreamState.RECONNECTING,
120
+ StreamState.RECONNECTED,
121
+ StreamState.STREAMING,
122
+ )
123
+
124
+ @JvmStatic
125
+ fun create(
126
+ context: Context,
127
+ listener: BluetoothSdkListener,
128
+ ): BluetoothSdk = create(context, BluetoothSdkConfig(), listener)
129
+
130
+ @JvmStatic
131
+ fun create(
132
+ context: Context,
133
+ config: BluetoothSdkConfig,
134
+ listener: BluetoothSdkListener,
135
+ ): BluetoothSdk = BluetoothSdk(context, config, listener)
136
+ }
137
+
138
+ private data class ActiveStreamKeepAlive(
139
+ val streamId: String,
140
+ val intervalMs: Long,
141
+ var pendingAckId: String? = null,
142
+ var missedAckCount: Int = 0,
143
+ var nextTick: Runnable? = null,
144
+ // Missed-ACK counting only begins once the stream is confirmed live/coming up, so a
145
+ // slow startup (glasses can't ACK until they reach starting/streaming) can't trip a
146
+ // false keep-alive timeout before the stream is ever up.
147
+ var armed: Boolean = false,
148
+ )
149
+
150
+ // seq records send order (assigned and handed to the BLE queue under
151
+ // streamStartOrderLock) so that an id-carrying status for a newer start can
152
+ // identify which older in-flight starts it preempted.
153
+ private data class PendingStreamStart(
154
+ val seq: Long,
155
+ val pending: PendingResponse<StreamStatusEvent>,
156
+ // Set when an id-carrying error arrives: a fatal publisher error never
157
+ // reaches the reconnect machinery and winds down with a streamId-less
158
+ // stopped, so the stash both attributes that stopped to this start and
159
+ // preserves the real error details for the rejection.
160
+ var lastError: StreamStatusEvent? = null,
161
+ )
162
+
163
+ private data class PendingStreamStop(
164
+ val streamId: String?,
165
+ // The stop's slot in the shared streamStartSeq order (drawn inside
166
+ // streamStartOrderLock at send time): every pending start with a
167
+ // lower seq reached the glasses' FIFO before this stop.
168
+ val seq: Long,
169
+ val pending: PendingResponse<StreamStatusEvent>,
170
+ )
171
+
172
+ private data class PendingVideoRecordingRequest(
173
+ val expectedStatus: String,
174
+ val pending: PendingResponse<VideoRecordingStatusEvent>,
175
+ val waitForUpload: Boolean = false,
176
+ var stoppedEvent: VideoRecordingStatusEvent? = null,
177
+ var uploadSucceeded: Boolean = false,
178
+ )
179
+
180
+ private data class PendingWifiScan(
181
+ val pending: PendingResponse<List<WifiScanResult>>,
182
+ val scanId: String,
183
+ var latestResults: List<WifiScanResult> = emptyList(),
184
+ var waiters: Int = 0,
185
+ // Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
186
+ // resolved only when the glasses flag the scan complete.
187
+ val accumulated: MutableList<WifiScanResult> = mutableListOf(),
188
+ )
189
+
190
+ private data class PendingWifiStatusRequest(
191
+ val operation: WifiStatusOperation,
192
+ val ssid: String,
193
+ val pending: PendingResponse<WifiStatusEvent>,
194
+ )
195
+
196
+ private enum class WifiStatusOperation {
197
+ CONNECT,
198
+ FORGET,
199
+ }
200
+
201
+ private data class PendingHotspotStatusRequest(
202
+ val enabled: Boolean,
203
+ val pending: PendingResponse<HotspotStatusEvent>,
204
+ )
205
+
206
+ // Suspends instead of parking a thread (OS-1714): the old CountDownLatch
207
+ // version blocked its caller for the whole glasses round-trip. Expo runs
208
+ // every plain AsyncFunction of every module on ONE shared thread
209
+ // (expo.modules.AsyncFunctionQueue), so a blocked await here froze all
210
+ // miniapp event delivery (Crust.jsDispatchToJs) until the glasses
211
+ // responded. resolve()/reject() stay callable from any thread.
212
+ private class PendingResponse<T>(
213
+ private val operation: String,
214
+ ) {
215
+ private val deferred = CompletableDeferred<T>()
216
+
217
+ fun resolve(value: T) {
218
+ deferred.complete(value)
219
+ }
220
+
221
+ fun reject(error: Throwable) {
222
+ deferred.completeExceptionally(error)
223
+ }
224
+
225
+ suspend fun await(timeoutMs: Long = DEFAULT_REQUEST_TIMEOUT_MS): T {
226
+ // All response types are non-null, so a null from withTimeoutOrNull
227
+ // can only mean the deadline elapsed. A rejected deferred rethrows
228
+ // its error from await() and propagates out unchanged.
229
+ return withTimeoutOrNull(timeoutMs) { deferred.await() }
230
+ ?: throw BluetoothSdkException(
231
+ "request_timeout",
232
+ "$operation timed out waiting for glasses response.",
233
+ )
234
+ }
235
+ }
236
+
237
+ fun addListener(listener: BluetoothSdkListener) {
238
+ listeners.add(listener)
239
+ }
240
+
241
+ fun removeListener(listener: BluetoothSdkListener) {
242
+ listeners.remove(listener)
243
+ }
244
+
245
+ fun getState(): BluetoothState =
246
+ BluetoothState.from(getRawGlassesStatus(), getRawBluetoothStatus())
247
+
248
+ fun getGlasses(): GlassesRuntimeState =
249
+ getState().glasses
250
+
251
+ fun getSdkState(): PhoneSdkRuntimeState =
252
+ getState().sdk
253
+
254
+ fun getScanState(): BluetoothScanState =
255
+ getState().scan
256
+
257
+ internal fun getRawGlassesStatus(): GlassesStatus =
258
+ GlassesStatus.fromMap(DeviceStore.store.getCategory("glasses"))
259
+
260
+ internal fun getRawBluetoothStatus(): BluetoothStatus =
261
+ BluetoothStatus.fromMap(DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY))
262
+
263
+ fun getDefaultDevice(): Device? = currentDefaultDevice()
264
+
265
+ private fun requireGlassesConnected(operation: String) {
266
+ if (!getRawGlassesStatus().connected) {
267
+ throw BluetoothSdkException(
268
+ "glasses_not_connected",
269
+ "Cannot $operation because glasses are not connected.",
270
+ )
271
+ }
272
+ }
273
+
274
+ fun setDefaultDevice(device: Device?) {
275
+ if (device == null) {
276
+ clearDefaultDevice()
277
+ return
278
+ }
279
+ suppressDefaultDeviceEvents = true
280
+ try {
281
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "default_wearable", device.model.deviceType)
282
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", device.name)
283
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", device.address ?: "")
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", "")
287
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
288
+ } finally {
289
+ suppressDefaultDeviceEvents = false
290
+ }
291
+ dispatchDefaultDeviceChanged()
292
+ }
293
+
294
+ fun clearDefaultDevice() {
295
+ suppressDefaultDeviceEvents = true
296
+ try {
297
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "default_wearable", "")
298
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_name", "")
299
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "device_address", "")
300
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", "")
301
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
302
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
303
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
304
+ } finally {
305
+ suppressDefaultDeviceEvents = false
306
+ }
307
+ dispatchDefaultDeviceChanged()
308
+ }
309
+
310
+ fun startScan(model: DeviceModel) {
311
+ if (model != DeviceModel.SIMULATED) {
312
+ requireBluetoothReady("scan for glasses")
313
+ }
314
+ discoveredDeviceNames.clear()
315
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "searching", true)
316
+ deviceManager.findCompatibleDevices(model.deviceType)
317
+ }
318
+
319
+ fun stopScan() {
320
+ stopScan(ScanStopReason.CANCELLED)
321
+ }
322
+
323
+ private fun stopScan(reason: ScanStopReason) {
324
+ deviceManager.stopScan()
325
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "searching", false)
326
+ dispatchToListeners { it.onScanStopped(reason) }
327
+ }
328
+
329
+ fun scan(
330
+ model: DeviceModel,
331
+ onResults: (List<Device>) -> Unit,
332
+ ): ScanSession = scan(model, DEFAULT_SCAN_TIMEOUT_MS, onResults)
333
+
334
+ fun scan(
335
+ model: DeviceModel,
336
+ timeoutMs: Long,
337
+ onResults: (List<Device>) -> Unit,
338
+ ): ScanSession =
339
+ scan(
340
+ model = model,
341
+ callback =
342
+ object : BluetoothScanCallback() {
343
+ override fun onResults(devices: List<Device>) {
344
+ onResults(devices)
345
+ }
346
+ },
347
+ timeoutMs = timeoutMs,
348
+ )
349
+
350
+ @JvmOverloads
351
+ fun scan(
352
+ model: DeviceModel,
353
+ callback: ScanCallback,
354
+ timeoutMs: Long = DEFAULT_SCAN_TIMEOUT_MS,
355
+ ): ScanSession {
356
+ val normalizedTimeoutMs = if (timeoutMs > 0) timeoutMs else DEFAULT_SCAN_TIMEOUT_MS
357
+ val latestResults = mutableListOf<Device>()
358
+ lateinit var timeoutRunnable: Runnable
359
+ lateinit var session: ScanSession
360
+ val finished = AtomicBoolean(false)
361
+
362
+ fun emitResults(devices: List<Device>) {
363
+ latestResults.clear()
364
+ latestResults.addAll(devices)
365
+ callback.onResults(latestResults.toList())
366
+ }
367
+
368
+ val scanListener =
369
+ object : BluetoothSdkCallback() {
370
+ override fun onScanChanged(scan: BluetoothScanState) {
371
+ emitResults(scan.devices.filter { it.model == model })
372
+ }
373
+ }
374
+
375
+ fun finish(reason: ScanStopReason) {
376
+ if (!finished.compareAndSet(false, true)) return
377
+ removeListener(scanListener)
378
+ mainHandler.removeCallbacks(timeoutRunnable)
379
+ session.markStopped()
380
+ stopScan(reason)
381
+ callback.onComplete(latestResults.toList())
382
+ }
383
+
384
+ timeoutRunnable = Runnable { finish(ScanStopReason.COMPLETED) }
385
+ session = ScanSession { finish(ScanStopReason.CANCELLED) }
386
+ addListener(scanListener)
387
+
388
+ try {
389
+ emitResults(emptyList())
390
+ startScan(model)
391
+ emitResults(getRawBluetoothStatus().searchResults.filter { it.model == model })
392
+ mainHandler.postDelayed(timeoutRunnable, normalizedTimeoutMs)
393
+ return session
394
+ } catch (error: Throwable) {
395
+ removeListener(scanListener)
396
+ mainHandler.removeCallbacks(timeoutRunnable)
397
+ session.markStopped()
398
+ callback.onError(error.toBluetoothError("scan_failed"))
399
+ throw error
400
+ }
401
+ }
402
+
403
+ @JvmOverloads
404
+ fun connect(device: Device, options: ConnectOptions = ConnectOptions()) {
405
+ if (device.model != DeviceModel.SIMULATED) {
406
+ requireBluetoothReady("connect to glasses")
407
+ }
408
+ val isController = ControllerTypes.ALL.contains(device.model.deviceType)
409
+ if (options.cancelExistingConnectionAttempt) {
410
+ if (isController) {
411
+ deviceManager.disconnectController()
412
+ } else {
413
+ cancelConnectionAttempt()
414
+ }
415
+ }
416
+ if (!isController) {
417
+ if (options.saveAsDefault) {
418
+ setDefaultDevice(device)
419
+ } else {
420
+ // Pairing uses saveAsDefault=false so default identity stays on the
421
+ // previous owner until handleDeviceReady. Stash the GATT target separately
422
+ // so MentraLive can connect by MAC without promoting getDefaultDevice().
423
+ DeviceStore.apply(
424
+ ObservableStore.BLUETOOTH_CATEGORY,
425
+ "pending_device_name",
426
+ device.name,
427
+ )
428
+ DeviceStore.apply(
429
+ ObservableStore.BLUETOOTH_CATEGORY,
430
+ "pending_device_address",
431
+ device.address ?: "",
432
+ )
433
+ DeviceStore.apply(
434
+ ObservableStore.BLUETOOTH_CATEGORY,
435
+ "pending_device_secure_pairing_capable",
436
+ device.securePairingCapable ?: "",
437
+ )
438
+ }
439
+ }
440
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_wearable", device.model.deviceType)
441
+ deviceManager.connectByName(device.name)
442
+ }
443
+
444
+ @JvmOverloads
445
+ fun connectDefault(options: ConnectOptions = ConnectOptions()) {
446
+ val defaultDevice =
447
+ currentDefaultDevice()
448
+ ?: throw BluetoothSdkException(
449
+ "default_device_missing",
450
+ "Set a default glasses device before calling connectDefault.",
451
+ )
452
+ if (defaultDevice.model != DeviceModel.SIMULATED) {
453
+ requireBluetoothReady("connect to glasses")
454
+ }
455
+ if (options.cancelExistingConnectionAttempt) {
456
+ cancelConnectionAttempt()
457
+ }
458
+ deviceManager.connectDefault()
459
+ }
460
+
461
+ fun cancelConnectionAttempt() {
462
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
463
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
464
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
465
+ deviceManager.disconnect()
466
+ }
467
+
468
+ internal fun connectSimulated() {
469
+ deviceManager.connectSimulated()
470
+ }
471
+
472
+ fun disconnect() {
473
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
474
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
475
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
476
+ deviceManager.disconnect()
477
+ }
478
+
479
+ fun forget() {
480
+ deviceManager.forget()
481
+ }
482
+
483
+ @JvmOverloads
484
+ fun displayText(text: String, x: Int = 0, y: Int = 0, size: Int = 24) {
485
+ displayText(DisplayTextRequest(text = text, x = x, y = y, size = size))
486
+ }
487
+
488
+ fun displayText(request: DisplayTextRequest) {
489
+ deviceManager.displayText(request.toMap())
490
+ }
491
+
492
+ internal fun displayEvent(request: DisplayEventRequest) {
493
+ deviceManager.displayEvent(request.toMap())
494
+ }
495
+
496
+ fun clearDisplay() {
497
+ deviceManager.clearDisplay()
498
+ }
499
+
500
+ fun showDashboard() {
501
+ deviceManager.showDashboard()
502
+ }
503
+
504
+ fun toggleHeadUp() {
505
+ deviceManager.headUp = !deviceManager.headUp
506
+ }
507
+
508
+ fun showNotificationsPanel() {
509
+ deviceManager.showNotificationsPanel()
510
+ }
511
+
512
+ // ---------- Native navigation (glasses-rendered nav UI) ----------
513
+
514
+ fun navigationModeStart() {
515
+ deviceManager.navigationModeStart()
516
+ }
517
+
518
+ fun navigationModeStop(arrived: Boolean) {
519
+ deviceManager.navigationModeStop(arrived)
520
+ }
521
+
522
+ fun navigationUpdateInfo(params: Map<String, Any?>) {
523
+ deviceManager.navigationUpdateInfo(params)
524
+ }
525
+
526
+ fun navigationMiniMap(params: Map<String, Any?>) {
527
+ deviceManager.navigationMiniMap(params)
528
+ }
529
+
530
+ fun navigationOverviewMap(params: Map<String, Any?>) {
531
+ deviceManager.navigationOverviewMap(params)
532
+ }
533
+
534
+ internal fun setBrightness(level: Int, autoMode: Boolean? = null) {
535
+ autoMode?.let { DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "auto_brightness", it) }
536
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "brightness", level)
537
+ }
538
+
539
+ internal fun setAutoBrightness(enabled: Boolean) {
540
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "auto_brightness", enabled)
541
+ }
542
+
543
+ fun setDashboardPosition(height: Int, depth: Int) {
544
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "dashboard_height", height)
545
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "dashboard_depth", depth)
546
+ }
547
+
548
+ fun setDashboardPosition(request: DashboardPositionRequest) {
549
+ setDashboardPosition(height = request.height, depth = request.depth)
550
+ }
551
+
552
+ internal fun setDashboardMenu(items: List<DashboardMenuItem>) {
553
+ DeviceStore.apply(
554
+ ObservableStore.BLUETOOTH_CATEGORY,
555
+ "menu_apps",
556
+ items.map { it.toMap() },
557
+ )
558
+ }
559
+
560
+ internal fun setCalendarEvents(events: List<CalendarEvent>) {
561
+ DeviceStore.apply(
562
+ ObservableStore.BLUETOOTH_CATEGORY,
563
+ "calendar_events",
564
+ events.map { it.toMap() },
565
+ )
566
+ }
567
+
568
+ fun setHeadUpAngle(angleDegrees: Int) {
569
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "head_up_angle", angleDegrees)
570
+ }
571
+
572
+ fun setScreenDisabled(disabled: Boolean) {
573
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "screen_disabled", disabled)
574
+ }
575
+
576
+ suspend fun setGalleryModeEnabled(enabled: Boolean): SettingsAckEvent =
577
+ performSettingsCommand(
578
+ setting = "gallery_mode",
579
+ updateStore = { _ -> DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "gallery_mode", enabled) },
580
+ send = { requestId -> deviceManager.sendGalleryMode(requestId, enabled) },
581
+ )
582
+
583
+ private suspend fun performSettingsCommand(
584
+ setting: String,
585
+ updateStore: (SettingsAckEvent) -> Unit,
586
+ send: (String) -> Unit,
587
+ ): SettingsAckEvent {
588
+ val requestId = "settings-$setting-${UUID.randomUUID()}"
589
+ val pending = PendingResponse<SettingsAckEvent>("set $setting")
590
+ pendingSettingsRequests[requestId] = pending
591
+ try {
592
+ send(requestId)
593
+ val ack = pending.await()
594
+ updateStore(ack)
595
+ return ack
596
+ } finally {
597
+ pendingSettingsRequests.remove(requestId, pending)
598
+ }
599
+ }
600
+
601
+ fun setVoiceActivityDetectionEnabled(enabled: Boolean) {
602
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "voice_activity_detection_enabled", enabled)
603
+ }
604
+
605
+ fun setLoudnessGateEnabled(enabled: Boolean) {
606
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "loudness_gate_enabled", enabled)
607
+ }
608
+
609
+ @Deprecated(
610
+ message =
611
+ "Sticky action-button photo presets are deprecated. Prefer per-request " +
612
+ "requestPhoto(...) options (e.g. mode=TEXT for text sensor size/crop, or explicit per-shot fields). " +
613
+ "This method still works but will be removed in a future release.",
614
+ )
615
+ suspend fun setPhotoCaptureDefaults(settings: PhotoCaptureDefaults): SettingsAckEvent =
616
+ performSettingsCommand(
617
+ setting = "button_photo",
618
+ updateStore = { _ ->
619
+ if (settings.resetCaptureTuning) {
620
+ // Clear all stored scan-tuning keys from phone cache
621
+ listOf(
622
+ "button_photo_zsl_mfnr",
623
+ "button_photo_mfnr",
624
+ "button_photo_zsl",
625
+ "button_photo_noise_reduction",
626
+ "button_photo_edge_enhancement",
627
+ "button_photo_isp_digital_gain",
628
+ "button_photo_isp_analog_gain",
629
+ "button_photo_ae_exposure_divisor",
630
+ "button_photo_iso_cap",
631
+ "button_photo_compress",
632
+ "button_photo_sound",
633
+ ).forEach { key ->
634
+ DeviceStore.store.remove(ObservableStore.BLUETOOTH_CATEGORY, key)
635
+ }
636
+ }
637
+ // Only update size if explicitly provided; omitted size = leave stored value unchanged
638
+ settings.size?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_size", it.value) }
639
+ settings.mfnr?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_mfnr", it) }
640
+ settings.zsl?.let { DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_zsl", it) }
641
+ settings.noiseReduction?.let {
642
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_noise_reduction", it)
643
+ }
644
+ settings.edgeEnhancement?.let {
645
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_edge_enhancement", it)
646
+ }
647
+ settings.ispDigitalGain?.let {
648
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_digital_gain", it)
649
+ }
650
+ settings.ispAnalogGain?.let {
651
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_isp_analog_gain", it)
652
+ }
653
+ settings.aeExposureDivisor?.let {
654
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_ae_exposure_divisor", it)
655
+ }
656
+ settings.isoCap?.let {
657
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_iso_cap", it)
658
+ }
659
+ settings.compress?.let {
660
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_compress", it)
661
+ }
662
+ settings.sound?.let {
663
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_photo_sound", it)
664
+ }
665
+ },
666
+ send = { requestId -> deviceManager.sendButtonPhotoSettings(requestId, settings) },
667
+ )
668
+
669
+ suspend fun setVideoRecordingDefaults(defaults: VideoRecordingDefaults): SettingsAckEvent =
670
+ performSettingsCommand(
671
+ setting = "button_video_recording",
672
+ updateStore = { _ ->
673
+ DeviceStore.set(
674
+ ObservableStore.BLUETOOTH_CATEGORY,
675
+ "button_video_settings",
676
+ mapOf("width" to defaults.width, "height" to defaults.height, "fps" to defaults.fps),
677
+ )
678
+ // Keep legacy cache keys readable for older internal callers during migration.
679
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_width", defaults.width)
680
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_height", defaults.height)
681
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_video_fps", defaults.fps)
682
+ },
683
+ send = { requestId ->
684
+ deviceManager.sendButtonVideoRecordingSettings(requestId, defaults.width, defaults.height, defaults.fps)
685
+ },
686
+ )
687
+
688
+ suspend fun setMaxVideoRecordingDuration(minutes: Int): SettingsAckEvent =
689
+ performSettingsCommand(
690
+ setting = "button_max_recording_time",
691
+ updateStore = { _ ->
692
+ DeviceStore.set(ObservableStore.BLUETOOTH_CATEGORY, "button_max_recording_time", minutes)
693
+ },
694
+ send = { requestId -> deviceManager.sendButtonMaxRecordingTime(requestId, minutes) },
695
+ )
696
+
697
+ suspend fun setCameraFov(fov: CameraFov): CameraFovResult {
698
+ val ack = performSettingsCommand(
699
+ setting = "camera_fov",
700
+ updateStore = { _ -> },
701
+ send = { requestId -> deviceManager.sendCameraFovSetting(requestId, fov.fov, fov.roiPosition.value) },
702
+ )
703
+ val result = CameraFovResult.fromAck(ack, fov)
704
+ DeviceStore.set(
705
+ ObservableStore.BLUETOOTH_CATEGORY,
706
+ "camera_fov",
707
+ mapOf("fov" to result.fov, "roi_position" to result.roiPosition.value),
708
+ )
709
+ return result
710
+ }
711
+
712
+ /**
713
+ * Compatibility path for ASG clients that support only the older one-way FOV command.
714
+ * It resolves once the setting is queued to BLE because those clients never acknowledge it.
715
+ */
716
+ fun setLegacyCameraFov(fov: CameraFov): CameraFovResult {
717
+ deviceManager.sendLegacyCameraFovSetting(fov.fov, fov.roiPosition.value)
718
+ return CameraFovResult("legacy", fov.fov, fov.roiPosition, System.currentTimeMillis())
719
+ }
720
+
721
+ /** Restores the persistent base FOV through the acknowledgement-free compatibility path. */
722
+ fun restoreLegacyCameraFov() {
723
+ deviceManager.restoreLegacyCameraFovSetting()
724
+ }
725
+
726
+ suspend fun setCameraFovOverride(leaseId: String, fov: CameraFov, ttlMs: Int): CameraFovResult {
727
+ val ack = performSettingsCommand(
728
+ setting = "camera_fov_override",
729
+ updateStore = { _ -> },
730
+ send = { requestId ->
731
+ deviceManager.sendCameraFovOverride(
732
+ requestId,
733
+ leaseId,
734
+ fov.fov,
735
+ fov.roiPosition.value,
736
+ ttlMs,
737
+ )
738
+ },
739
+ )
740
+ return CameraFovResult.fromAck(ack, fov)
741
+ }
742
+
743
+ suspend fun releaseCameraFovOverride(leaseId: String): SettingsAckEvent =
744
+ performSettingsCommand(
745
+ setting = "camera_fov_override",
746
+ updateStore = { _ -> },
747
+ send = { requestId -> deviceManager.releaseCameraFovOverride(requestId, leaseId) },
748
+ )
749
+
750
+ /**
751
+ * Configure camera HAL tuning on Mentra Live glasses.
752
+ *
753
+ * The phone sends a {@code camera_tuning_config} BLE message; the ASG client relays it as a
754
+ * {@code camconfig} broadcast to SystemUI's CTReceiver so the camera HAL picks up the new
755
+ * parameters without a reboot.
756
+ *
757
+ * @param anrOn {@code true} = ANR enabled, {@code false} = ANR disabled (pixsmart param)
758
+ * @param gainOn {@code true} = stock gain params, {@code false} = pixsmart gain-off params
759
+ */
760
+ suspend fun setCameraTuningConfig(anrOn: Boolean, gainOn: Boolean): SettingsAckEvent =
761
+ performSettingsCommand(
762
+ setting = "camera_tuning",
763
+ updateStore = { _ -> },
764
+ send = { requestId -> deviceManager.sendCameraTuningConfig(requestId, anrOn, gainOn) },
765
+ )
766
+
767
+ fun setMicState(
768
+ enabled: Boolean,
769
+ useGlassesMic: Boolean = true,
770
+ sendTranscript: Boolean = false,
771
+ sendLc3Data: Boolean = false,
772
+ ) {
773
+ if (enabled) {
774
+ DeviceStore.apply(
775
+ ObservableStore.BLUETOOTH_CATEGORY,
776
+ "preferred_mic",
777
+ if (useGlassesMic) MicPreference.GLASSES.value else MicPreference.PHONE.value,
778
+ )
779
+ }
780
+ applyMicState(
781
+ sendPcmData = enabled,
782
+ sendTranscript = enabled && sendTranscript,
783
+ sendLc3Data = enabled && sendLc3Data,
784
+ )
785
+ }
786
+
787
+ private fun applyMicState(
788
+ sendPcmData: Boolean,
789
+ sendTranscript: Boolean,
790
+ sendLc3Data: Boolean,
791
+ ) {
792
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "should_send_pcm", sendPcmData)
793
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "should_send_lc3", sendLc3Data)
794
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "should_send_transcript", sendTranscript)
795
+ deviceManager.setMicState()
796
+ }
797
+
798
+ fun setPreferredMic(preferredMic: MicPreference) {
799
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "preferred_mic", preferredMic.value)
800
+ }
801
+
802
+ fun setOwnAppAudioPlaying(playing: Boolean) {
803
+ PhoneAudioMonitor.getInstance(appContext).setOwnAppAudioPlaying(playing)
804
+ }
805
+
806
+ fun getGlassesMediaVolume(): GlassesMediaVolumeGetResult =
807
+ GlassesMediaVolumeGetResult.fromMap(deviceManager.getGlassesMediaVolumeBlocking())
808
+
809
+ fun setGlassesMediaVolume(level: Int): GlassesMediaVolumeSetResult {
810
+ require(level in 0..15) { "Glasses media volume must be between 0 and 15." }
811
+ return GlassesMediaVolumeSetResult.fromMap(deviceManager.setGlassesMediaVolumeBlocking(level))
812
+ }
813
+
814
+ suspend fun requestWifiScan(): List<WifiScanResult> {
815
+ val pending = PendingResponse<List<WifiScanResult>>("WiFi scan request")
816
+ val request = PendingWifiScan(pending, scanId = "scan-${UUID.randomUUID()}", waiters = 1)
817
+ val existing =
818
+ synchronized(oneShotLock) {
819
+ pendingWifiScan?.also { it.waiters++ } ?: run {
820
+ pendingWifiScan = request
821
+ null
822
+ }
823
+ }
824
+ if (existing != null) {
825
+ // Join the in-flight scan instead of failing with request_in_flight;
826
+ // the scan screen auto-starts a scan on mount and can be pushed twice.
827
+ try {
828
+ return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
829
+ } finally {
830
+ releaseWifiScanWaiter(existing)
831
+ }
832
+ }
833
+ try {
834
+ // Claim the scan-results store for this scan before the command goes
835
+ // out, so a delayed chunk from an older, abandoned scan can no longer
836
+ // mutate it.
837
+ Bridge.claimWifiScanResults(request.scanId)
838
+ deviceManager.requestWifiScan(request.scanId)
839
+ // The glasses wait up to 15s for scan-results broadcasts before sending
840
+ // scan_complete, so give them longer than that before falling back.
841
+ return pending.await(WIFI_SCAN_TIMEOUT_MS)
842
+ } catch (cancellation: CancellationException) {
843
+ // Only this caller is going away; the glasses scan keeps running and
844
+ // other coroutines may have joined the same pending entry. Leave the
845
+ // shared deferred alone so scan_complete can still resolve for them.
846
+ throw cancellation
847
+ } catch (error: Throwable) {
848
+ if (error is BluetoothSdkException && error.code == "request_timeout") {
849
+ val fallbackResults = synchronized(oneShotLock) { request.latestResults }
850
+ if (fallbackResults.isNotEmpty()) {
851
+ // Resolve so callers joined on the same scan get the fallback too.
852
+ pending.resolve(fallbackResults)
853
+ return fallbackResults
854
+ }
855
+ }
856
+ // Fail joined callers immediately instead of letting them run out their
857
+ // own timeout.
858
+ pending.reject(error)
859
+ throw error
860
+ } finally {
861
+ releaseWifiScanWaiter(request)
862
+ }
863
+ }
864
+
865
+ /** Last waiter (starter or joiner) out clears the shared scan entry so a
866
+ * cancelled starter can't strand it, and an abandoned scan can't wedge
867
+ * future ones. handleWifiScanResultsForRequests may clear it first. */
868
+ private fun releaseWifiScanWaiter(request: PendingWifiScan) {
869
+ synchronized(oneShotLock) {
870
+ request.waiters--
871
+ if (request.waiters <= 0 && pendingWifiScan === request) {
872
+ pendingWifiScan = null
873
+ }
874
+ }
875
+ }
876
+
877
+ suspend fun sendWifiCredentials(ssid: String, password: String): WifiStatusEvent {
878
+ val pending = PendingResponse<WifiStatusEvent>("WiFi connect request")
879
+ synchronized(oneShotLock) {
880
+ if (pendingWifiStatus != null) {
881
+ throw BluetoothSdkException(
882
+ "request_in_flight",
883
+ "A WiFi status command is already waiting for a glasses response.",
884
+ )
885
+ }
886
+ pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.CONNECT, ssid, pending)
887
+ }
888
+ try {
889
+ deviceManager.sendWifiCredentials(ssid, password)
890
+ return pending.await()
891
+ } finally {
892
+ synchronized(oneShotLock) {
893
+ if (pendingWifiStatus?.pending === pending) {
894
+ pendingWifiStatus = null
895
+ }
896
+ }
897
+ }
898
+ }
899
+
900
+ suspend fun forgetWifiNetwork(ssid: String): WifiStatusEvent {
901
+ val pending = PendingResponse<WifiStatusEvent>("WiFi forget request")
902
+ synchronized(oneShotLock) {
903
+ if (pendingWifiStatus != null) {
904
+ throw BluetoothSdkException(
905
+ "request_in_flight",
906
+ "A WiFi status command is already waiting for a glasses response.",
907
+ )
908
+ }
909
+ pendingWifiStatus = PendingWifiStatusRequest(WifiStatusOperation.FORGET, ssid, pending)
910
+ }
911
+ try {
912
+ deviceManager.forgetWifiNetwork(ssid)
913
+ return pending.await()
914
+ } finally {
915
+ synchronized(oneShotLock) {
916
+ if (pendingWifiStatus?.pending === pending) {
917
+ pendingWifiStatus = null
918
+ }
919
+ }
920
+ }
921
+ }
922
+
923
+ suspend fun setHotspotState(enabled: Boolean): HotspotStatusEvent {
924
+ val pending = PendingResponse<HotspotStatusEvent>("hotspot ${if (enabled) "enable" else "disable"} request")
925
+ synchronized(oneShotLock) {
926
+ if (pendingHotspotStatus != null) {
927
+ throw BluetoothSdkException(
928
+ "request_in_flight",
929
+ "A hotspot command is already waiting for a glasses response.",
930
+ )
931
+ }
932
+ pendingHotspotStatus = PendingHotspotStatusRequest(enabled, pending)
933
+ }
934
+ try {
935
+ deviceManager.setHotspotState(enabled)
936
+ return pending.await()
937
+ } finally {
938
+ synchronized(oneShotLock) {
939
+ if (pendingHotspotStatus?.pending === pending) {
940
+ pendingHotspotStatus = null
941
+ }
942
+ }
943
+ }
944
+ }
945
+
946
+ fun setWifiAdbState(enabled: Boolean) {
947
+ deviceManager.setWifiAdbState(enabled)
948
+ }
949
+
950
+ fun setSystemTime(timestampMs: Long) {
951
+ deviceManager.setSystemTime(timestampMs)
952
+ }
953
+
954
+ suspend fun requestPhoto(request: PhotoRequest): PhotoResponseEvent {
955
+ val routedRequest =
956
+ nonBlankRequestId(request.requestId)
957
+ ?.let { if (it == request.requestId) request else request.copy(requestId = it) }
958
+ ?: request.copy(requestId = generatedCameraRequestId("photo"))
959
+ Bridge.log(
960
+ "NATIVE: PHOTO PIPELINE [3b/6] BluetoothSdk.requestPhoto requestId=${routedRequest.requestId}"
961
+ )
962
+ val pending = PendingResponse<PhotoResponseEvent>("photo request ${routedRequest.requestId}")
963
+ pendingPhotoRequests[routedRequest.requestId] = pending
964
+ try {
965
+ deviceManager.requestPhoto(routedRequest)
966
+ return pending.await(PHOTO_REQUEST_TIMEOUT_MS)
967
+ } finally {
968
+ pendingPhotoRequests.remove(routedRequest.requestId, pending)
969
+ }
970
+ }
971
+
972
+ suspend fun warmUpCamera(
973
+ requestId: String? = null,
974
+ size: PhotoSize,
975
+ mode: PhotoMode = PhotoMode.PHOTO,
976
+ exposureTimeNs: Long?,
977
+ durationMs: Int,
978
+ zsl: Boolean? = null,
979
+ mfnr: Boolean? = null,
980
+ ): CameraStatusEvent {
981
+ val effectiveRequestId = nonBlankRequestId(requestId) ?: generatedCameraRequestId("warm")
982
+ val pending = PendingResponse<CameraStatusEvent>("camera warm up $effectiveRequestId")
983
+ // Guard against a concurrent warm-up reusing the same requestId: a blind put would overwrite
984
+ // (and strand) the first caller's continuation until it times out. Reject the duplicate.
985
+ if (pendingCameraStatusRequests.putIfAbsent(effectiveRequestId, pending) != null) {
986
+ throw BluetoothSdkException(
987
+ "request_in_flight",
988
+ "A camera warm-up request with this requestId is already waiting for a glasses response.",
989
+ )
990
+ }
991
+ try {
992
+ deviceManager.warmUpCamera(effectiveRequestId, size, mode, exposureTimeNs, durationMs, zsl, mfnr)
993
+ return pending.await()
994
+ } finally {
995
+ pendingCameraStatusRequests.remove(effectiveRequestId, pending)
996
+ }
997
+ }
998
+
999
+ fun stopCameraWarmUp(requestId: String) {
1000
+ val effectiveRequestId = nonBlankRequestId(requestId)
1001
+ ?: throw BluetoothSdkException("invalid_request", "A warm-up request ID is required.")
1002
+ deviceManager.stopCameraWarmUp(effectiveRequestId)
1003
+ }
1004
+
1005
+ suspend fun queryGalleryStatus(): GalleryStatusEvent {
1006
+ val pending = PendingResponse<GalleryStatusEvent>("gallery status query")
1007
+ synchronized(oneShotLock) {
1008
+ if (pendingGalleryStatus != null) {
1009
+ throw BluetoothSdkException(
1010
+ "request_in_flight",
1011
+ "A gallery status query is already waiting for a glasses response.",
1012
+ )
1013
+ }
1014
+ pendingGalleryStatus = pending
1015
+ }
1016
+ try {
1017
+ deviceManager.queryGalleryStatus()
1018
+ return pending.await()
1019
+ } finally {
1020
+ synchronized(oneShotLock) {
1021
+ if (pendingGalleryStatus === pending) {
1022
+ pendingGalleryStatus = null
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ suspend fun startStream(request: StreamRequest): StreamStatusEvent =
1029
+ startStream(request, startSdkKeepAlive = true)
1030
+
1031
+ internal suspend fun startExternallyManagedStream(request: StreamRequest): StreamStatusEvent =
1032
+ startStream(request, startSdkKeepAlive = false)
1033
+
1034
+ private suspend fun startStream(
1035
+ request: StreamRequest,
1036
+ startSdkKeepAlive: Boolean,
1037
+ ): StreamStatusEvent {
1038
+ val message = request.toMap().toMutableMap()
1039
+ val streamId = (message["streamId"] as? String)?.takeIf { it.isNotBlank() }
1040
+ ?: "sdk-${UUID.randomUUID()}"
1041
+ message["streamId"] = streamId
1042
+ val pending = PendingResponse<StreamStatusEvent>("start stream $streamId")
1043
+ var start: PendingStreamStart? = null
1044
+ try {
1045
+ // seq assignment and the BLE hand-off must be one critical section:
1046
+ // rejectPreemptedStreamStarts only works if seq order equals the
1047
+ // order the commands reach the glasses' FIFO.
1048
+ synchronized(streamStartOrderLock) {
1049
+ val registered = PendingStreamStart(streamStartSeq.incrementAndGet(), pending)
1050
+ pendingStreamStarts[streamId] = registered
1051
+ start = registered
1052
+ stopStreamKeepAliveMonitor()
1053
+ deviceManager.startStream(message)
1054
+ }
1055
+ val event = pending.await(STREAM_START_TIMEOUT_MS)
1056
+ if (startSdkKeepAlive) {
1057
+ startStreamKeepAliveMonitor(streamId, DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS)
1058
+ }
1059
+ return event
1060
+ } finally {
1061
+ start?.let { pendingStreamStarts.remove(streamId, it) }
1062
+ }
1063
+ }
1064
+
1065
+ internal fun sendExternallyManagedStreamKeepAlive(request: StreamKeepAliveRequest) {
1066
+ deviceManager.keepStreamAlive(request.toMap().toMutableMap())
1067
+ }
1068
+
1069
+ suspend fun rgbLedControl(request: RgbLedRequest): RgbLedControlResponseEvent {
1070
+ val pending = PendingResponse<RgbLedControlResponseEvent>("RGB LED command ${request.requestId}")
1071
+ pendingRgbLedRequests[request.requestId] = pending
1072
+ try {
1073
+ deviceManager.rgbLedControl(
1074
+ request.requestId,
1075
+ request.packageName,
1076
+ request.action.value,
1077
+ request.color?.value,
1078
+ request.onDurationMs,
1079
+ request.offDurationMs,
1080
+ request.count,
1081
+ )
1082
+ return pending.await()
1083
+ } finally {
1084
+ pendingRgbLedRequests.remove(request.requestId, pending)
1085
+ }
1086
+ }
1087
+
1088
+ suspend fun stopStream(): StreamStatusEvent {
1089
+ val pending = PendingResponse<StreamStatusEvent>("stop stream")
1090
+ val targetStreamId = synchronized(streamKeepAliveLock) {
1091
+ activeStreamKeepAlive?.streamId
1092
+ }
1093
+ try {
1094
+ // The seq draw and the BLE hand-off share startStream's ordering
1095
+ // critical section: the stop takes its own slot in the send order,
1096
+ // so its ack can separate the pending starts the glasses consumed
1097
+ // before it (lower seq) from starts sent after it (higher seq).
1098
+ synchronized(streamStartOrderLock) {
1099
+ synchronized(oneShotLock) {
1100
+ if (pendingStreamStop != null) {
1101
+ throw BluetoothSdkException(
1102
+ "request_in_flight",
1103
+ "A stream stop command is already waiting for a glasses response.",
1104
+ )
1105
+ }
1106
+ pendingStreamStop =
1107
+ PendingStreamStop(targetStreamId, streamStartSeq.incrementAndGet(), pending)
1108
+ }
1109
+ stopStreamKeepAliveMonitor()
1110
+ deviceManager.stopStream()
1111
+ }
1112
+ return pending.await(STREAM_STOP_TIMEOUT_MS)
1113
+ } finally {
1114
+ synchronized(oneShotLock) {
1115
+ if (pendingStreamStop?.pending === pending) {
1116
+ pendingStreamStop = null
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ suspend fun startVideoRecording(request: VideoRecordingRequest): VideoRecordingStatusEvent {
1123
+ require(request.requestId.isNotBlank()) { "requestId is required to start video recording." }
1124
+ requireGlassesConnected("start video recording")
1125
+ val pending = PendingResponse<VideoRecordingStatusEvent>("start video recording")
1126
+ val pendingRequest = PendingVideoRecordingRequest("recording_started", pending)
1127
+ if (pendingVideoRecordingRequests.putIfAbsent(request.requestId, pendingRequest) != null) {
1128
+ throw BluetoothSdkException(
1129
+ "request_in_flight",
1130
+ "A video recording command is already waiting for requestId ${request.requestId}.",
1131
+ )
1132
+ }
1133
+ try {
1134
+ deviceManager.startVideoRecording(
1135
+ request.requestId,
1136
+ request.save,
1137
+ request.sound,
1138
+ request.width,
1139
+ request.height,
1140
+ request.fps,
1141
+ request.maxRecordingTimeMinutes,
1142
+ )
1143
+ return pending.await()
1144
+ } finally {
1145
+ pendingVideoRecordingRequests.remove(request.requestId, pendingRequest)
1146
+ }
1147
+ }
1148
+
1149
+ @JvmOverloads
1150
+ suspend fun stopVideoRecording(
1151
+ requestId: String,
1152
+ webhookUrl: String? = null,
1153
+ authToken: String? = null,
1154
+ ): VideoRecordingStatusEvent {
1155
+ require(requestId.isNotBlank()) { "requestId is required to stop video recording." }
1156
+ requireGlassesConnected("stop video recording")
1157
+ val pending = PendingResponse<VideoRecordingStatusEvent>("stop video recording")
1158
+ val waitForUpload = !webhookUrl.isNullOrBlank()
1159
+ val pendingRequest =
1160
+ PendingVideoRecordingRequest(
1161
+ expectedStatus = "recording_stopped",
1162
+ pending = pending,
1163
+ waitForUpload = waitForUpload,
1164
+ )
1165
+ if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
1166
+ throw BluetoothSdkException(
1167
+ "request_in_flight",
1168
+ "A video recording command is already waiting for requestId $requestId.",
1169
+ )
1170
+ }
1171
+ try {
1172
+ deviceManager.stopVideoRecording(requestId, webhookUrl, authToken)
1173
+ val timeoutMs =
1174
+ if (waitForUpload) VIDEO_UPLOAD_STOP_TIMEOUT_MS else DEFAULT_REQUEST_TIMEOUT_MS
1175
+ return pending.await(timeoutMs)
1176
+ } finally {
1177
+ pendingVideoRecordingRequests.remove(requestId, pendingRequest)
1178
+ }
1179
+ }
1180
+
1181
+ suspend fun queryVideoRecordingStatus(requestId: String): VideoRecordingStatusEvent {
1182
+ require(requestId.isNotBlank()) { "requestId is required to query video recording status." }
1183
+ requireGlassesConnected("query video recording status")
1184
+ val pending = PendingResponse<VideoRecordingStatusEvent>("query video recording status")
1185
+ val pendingRequest = PendingVideoRecordingRequest("recording_status", pending)
1186
+ if (pendingVideoRecordingRequests.putIfAbsent(requestId, pendingRequest) != null) {
1187
+ throw BluetoothSdkException(
1188
+ "request_in_flight",
1189
+ "A video recording command is already waiting for requestId $requestId.",
1190
+ )
1191
+ }
1192
+ try {
1193
+ deviceManager.queryVideoRecordingStatus(requestId)
1194
+ return pending.await()
1195
+ } finally {
1196
+ pendingVideoRecordingRequests.remove(requestId, pendingRequest)
1197
+ }
1198
+ }
1199
+
1200
+ suspend fun requestVersionInfo(): VersionInfoResult {
1201
+ val pending = PendingResponse<VersionInfoResult>("version info request")
1202
+ synchronized(oneShotLock) {
1203
+ if (pendingVersionInfo != null) {
1204
+ throw BluetoothSdkException(
1205
+ "request_in_flight",
1206
+ "A version info request is already waiting for a glasses response.",
1207
+ )
1208
+ }
1209
+ pendingVersionInfo = pending
1210
+ }
1211
+ try {
1212
+ deviceManager.requestVersionInfo()
1213
+ return pending.await()
1214
+ } finally {
1215
+ synchronized(oneShotLock) {
1216
+ if (pendingVersionInfo === pending) {
1217
+ pendingVersionInfo = null
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ fun availableG2FirmwareArtifacts(): List<Map<String, Any>> {
1224
+ requireGlassesConnected("list G2 firmware")
1225
+ return deviceManager.availableG2FirmwareArtifacts()
1226
+ }
1227
+
1228
+ suspend fun startG2FirmwareFlash(target: String): Map<String, Any> {
1229
+ requireGlassesConnected("flash G2 firmware")
1230
+ return deviceManager.startG2FirmwareFlash(target)
1231
+ }
1232
+
1233
+ /** Select the HTTP(S) manifest used by subsequent OTA checks and installs. */
1234
+ fun setOtaVersionUrl(otaVersionUrl: String) {
1235
+ configuredOtaVersionUrl = OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
1236
+ }
1237
+
1238
+ /** Return the configured OTA manifest, or this SDK release's default manifest. */
1239
+ fun getOtaVersionUrl(): String = configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1240
+
1241
+ /** Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available. */
1242
+ suspend fun checkForOtaUpdate(): Boolean {
1243
+ val status = getFreshGlassesStatus()
1244
+ if (!status.connected) {
1245
+ throw BluetoothSdkException(
1246
+ "glasses_not_connected",
1247
+ "Cannot check OTA update because glasses are not connected.",
1248
+ )
1249
+ }
1250
+ if (status.buildNumber.isBlank()) {
1251
+ throw BluetoothSdkException(
1252
+ "missing_glasses_version",
1253
+ "Cannot check OTA update because glasses build number is unavailable.",
1254
+ )
1255
+ }
1256
+
1257
+ val manifestUrl = resolveOtaVersionUrl(status)
1258
+ val manifest = OtaManifestChecker.fetch(manifestUrl)
1259
+ val otaStatus = waitForOtaManifestStatus(status, manifest)
1260
+ return OtaManifestChecker.hasUpdate(
1261
+ otaStatus.buildNumber,
1262
+ otaStatus.mtkFirmwareVersion,
1263
+ otaStatus.besFirmwareVersion,
1264
+ manifest,
1265
+ )
1266
+ }
1267
+
1268
+ /** Return bundled release changelogs crossed between two coordinated product versions, newest first. */
1269
+ fun getReleaseChangelogs(
1270
+ fromVersion: String? = null,
1271
+ toVersion: String? = null,
1272
+ ): List<ReleaseChangelog> = ReleaseChangelogCatalog.select(fromVersion, toVersion)
1273
+
1274
+ /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
1275
+ private suspend fun queryOtaStatus(): OtaQueryResult =
1276
+ performOtaQuery("OTA status query") {
1277
+ deviceManager.sendOtaQueryStatus()
1278
+ }
1279
+
1280
+ private suspend fun performOtaQuery(
1281
+ operation: String,
1282
+ sendRequest: () -> Unit,
1283
+ ): OtaQueryResult {
1284
+ val pending = PendingResponse<OtaQueryResult>(operation)
1285
+ synchronized(oneShotLock) {
1286
+ if (pendingOtaQuery != null) {
1287
+ throw BluetoothSdkException(
1288
+ "request_in_flight",
1289
+ "An OTA status query is already waiting for a glasses response.",
1290
+ )
1291
+ }
1292
+ pendingOtaQuery = pending
1293
+ }
1294
+ try {
1295
+ sendRequest()
1296
+ return pending.await()
1297
+ } finally {
1298
+ synchronized(oneShotLock) {
1299
+ if (pendingOtaQuery === pending) {
1300
+ pendingOtaQuery = null
1301
+ }
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ /** Start the OTA flow after your app has presented the available update to the user. */
1307
+ suspend fun startOtaUpdate(): OtaStartAckEvent {
1308
+ val otaVersionUrl = resolveOtaVersionUrl(getFreshGlassesStatus())
1309
+ return startOtaUpdate(otaVersionUrl)
1310
+ }
1311
+
1312
+ private suspend fun startOtaCommand(otaVersionUrl: String): OtaStartAckEvent {
1313
+ val pending = PendingResponse<OtaStartAckEvent>("OTA start command")
1314
+ synchronized(oneShotLock) {
1315
+ if (pendingOtaStart != null) {
1316
+ throw BluetoothSdkException(
1317
+ "request_in_flight",
1318
+ "An OTA start command is already waiting for a glasses response.",
1319
+ )
1320
+ }
1321
+ pendingOtaStart = pending
1322
+ }
1323
+ try {
1324
+ deviceManager.sendOtaStart(otaVersionUrl)
1325
+ return pending.await()
1326
+ } finally {
1327
+ synchronized(oneShotLock) {
1328
+ if (pendingOtaStart === pending) {
1329
+ pendingOtaStart = null
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
+ internal suspend fun startOtaUpdate(otaVersionUrl: String): OtaStartAckEvent =
1336
+ startOtaCommand(otaVersionUrl)
1337
+
1338
+ internal suspend fun sendOtaQueryStatus(): OtaQueryResult = queryOtaStatus()
1339
+
1340
+ fun startAr99OtaFromFile(path: String): Boolean {
1341
+ requireGlassesConnected("start AR99 OTA")
1342
+ return deviceManager.startAr99OtaFromFile(path)
1343
+ }
1344
+
1345
+ fun cancelAr99Ota() {
1346
+ deviceManager.cancelAr99Ota()
1347
+ }
1348
+
1349
+ fun sendAr99FactoryReset() {
1350
+ requireGlassesConnected("factory reset AR99")
1351
+ deviceManager.sendAr99FactoryReset()
1352
+ }
1353
+
1354
+ private suspend fun getFreshGlassesStatus(): GlassesStatus {
1355
+ val status = getRawGlassesStatus()
1356
+ if (!status.connected || status.buildNumber.isNotBlank()) {
1357
+ return status
1358
+ }
1359
+
1360
+ return try {
1361
+ val versionInfo = requestVersionInfo()
1362
+ status.copy(
1363
+ androidVersion = versionInfo.androidVersion.ifBlank { status.androidVersion },
1364
+ firmwareVersion = versionInfo.firmwareVersion.ifBlank { status.firmwareVersion },
1365
+ besFirmwareVersion = versionInfo.besFirmwareVersion.ifBlank { status.besFirmwareVersion },
1366
+ mtkFirmwareVersion = versionInfo.mtkFirmwareVersion.ifBlank { status.mtkFirmwareVersion },
1367
+ buildNumber = versionInfo.buildNumber.ifBlank { status.buildNumber },
1368
+ systemTimeMs = versionInfo.systemTimeMs ?: status.systemTimeMs,
1369
+ otaVersionUrl = versionInfo.otaVersionUrl.ifBlank { status.otaVersionUrl },
1370
+ appVersion = versionInfo.appVersion.ifBlank { status.appVersion },
1371
+ hotspotOtaVersion =
1372
+ if (versionInfo.hotspotOtaVersion > 0) {
1373
+ versionInfo.hotspotOtaVersion
1374
+ } else {
1375
+ status.hotspotOtaVersion
1376
+ },
1377
+ )
1378
+ } catch (cancellation: CancellationException) {
1379
+ // Never swallow cancellation into the stale-status fallback: callers
1380
+ // like startOtaUpdate() would otherwise keep running side effects
1381
+ // (sendOtaStart) on behalf of an already-cancelled coroutine.
1382
+ throw cancellation
1383
+ } catch (_: Throwable) {
1384
+ status
1385
+ }
1386
+ }
1387
+
1388
+ private suspend fun waitForOtaManifestStatus(
1389
+ initialStatus: GlassesStatus,
1390
+ manifest: org.json.JSONObject,
1391
+ ): GlassesStatus {
1392
+ var status = initialStatus
1393
+ if (OtaManifestChecker.hasBesFirmware(manifest) && status.besFirmwareVersion.isBlank()) {
1394
+ status = waitForGlassesStatus(status, OTA_BES_VERSION_WAIT_MS) {
1395
+ !it.connected || it.besFirmwareVersion.isNotBlank()
1396
+ }
1397
+ }
1398
+
1399
+ if (OtaManifestChecker.hasMtkPatches(manifest) && status.mtkFirmwareVersion.isBlank()) {
1400
+ status = waitForGlassesStatus(status, OTA_MTK_VERSION_WAIT_MS) {
1401
+ !it.connected || it.mtkFirmwareVersion.isNotBlank()
1402
+ }
1403
+ }
1404
+
1405
+ if (!status.connected) {
1406
+ throw BluetoothSdkException(
1407
+ "glasses_not_connected",
1408
+ "Cannot check OTA update because glasses disconnected.",
1409
+ )
1410
+ }
1411
+ return status
1412
+ }
1413
+
1414
+ private suspend fun waitForGlassesStatus(
1415
+ initialStatus: GlassesStatus,
1416
+ timeoutMs: Long,
1417
+ isReady: (GlassesStatus) -> Boolean,
1418
+ ): GlassesStatus {
1419
+ val deadline = System.currentTimeMillis() + timeoutMs
1420
+ var status = initialStatus
1421
+ while (System.currentTimeMillis() < deadline) {
1422
+ status = getRawGlassesStatus()
1423
+ if (isReady(status)) return status
1424
+ val remainingMs = deadline - System.currentTimeMillis()
1425
+ if (remainingMs <= 0L) break
1426
+ delay(minOf(OTA_VERSION_POLL_MS, remainingMs))
1427
+ }
1428
+ return getRawGlassesStatus()
1429
+ }
1430
+
1431
+ private fun resolveOtaVersionUrl(status: GlassesStatus): String {
1432
+ val deviceUrl = status.otaVersionUrl.trim()
1433
+ if (isLegacyAsgOtaStartBuild(status.buildNumber)) {
1434
+ return OtaManifestDefaults.LEGACY_PROD_OTA_VERSION_URL
1435
+ }
1436
+ // SDK consumers are pinned to the manifest built for their SDK version.
1437
+ // A future glasses-advertised URL should not silently change that pairing.
1438
+ return configuredOtaVersionUrl ?: OtaManifestDefaults.defaultOtaVersionUrl()
1439
+ }
1440
+
1441
+ private fun isLegacyAsgOtaStartBuild(buildNumber: String): Boolean {
1442
+ val parsed = buildNumber.toIntOrNull()
1443
+ return parsed != null && parsed < 39
1444
+ }
1445
+
1446
+ internal fun sendShutdown() {
1447
+ deviceManager.sendShutdown()
1448
+ }
1449
+
1450
+ internal fun sendReboot() {
1451
+ deviceManager.sendReboot()
1452
+ }
1453
+
1454
+ internal fun sendIncidentId(incidentId: String, apiBaseUrl: String? = null) {
1455
+ deviceManager.sendIncidentId(incidentId, apiBaseUrl)
1456
+ }
1457
+
1458
+ override fun close() {
1459
+ stopStreamKeepAliveMonitor()
1460
+ if (activityLifecycleCallbacksRegistered) {
1461
+ (appContext as? Application)?.unregisterActivityLifecycleCallbacks(
1462
+ activityLifecycleCallbacks,
1463
+ )
1464
+ activityLifecycleCallbacksRegistered = false
1465
+ }
1466
+ Bridge.removeEventSink(bridgeEventSinkId)
1467
+ DeviceStore.store.removeListener(storeListenerId)
1468
+ analytics.shutdown()
1469
+ listeners.clear()
1470
+ }
1471
+
1472
+ private fun dispatchStoreUpdate(category: String, changes: Map<String, Any>) {
1473
+ when (ObservableStore.normalizeCategory(category)) {
1474
+ "glasses" -> {
1475
+ analytics.observeGlassesStatus(getRawGlassesStatus())
1476
+ val state = getState()
1477
+ dispatchToListeners {
1478
+ it.onStateChanged(state)
1479
+ it.onGlassesChanged(state.glasses)
1480
+ }
1481
+ }
1482
+ ObservableStore.BLUETOOTH_CATEGORY -> {
1483
+ val state = getState()
1484
+ val scanChanged = changes.keys.any { it in SCAN_STATE_KEYS }
1485
+ dispatchToListeners {
1486
+ it.onStateChanged(state)
1487
+ it.onSdkStateChanged(state.sdk)
1488
+ if (scanChanged) {
1489
+ it.onScanChanged(state.scan)
1490
+ }
1491
+ }
1492
+ if (!suppressDefaultDeviceEvents && changes.keys.any { it in DEFAULT_DEVICE_KEYS }) {
1493
+ dispatchDefaultDeviceChanged()
1494
+ }
1495
+ dispatchDiscoveredDevices(changes["searchResults"])
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ private fun dispatchDefaultDeviceChanged() {
1501
+ val defaultDevice = currentDefaultDevice()
1502
+ dispatchToListeners { it.onDefaultDeviceChanged(defaultDevice) }
1503
+ }
1504
+
1505
+ private fun currentDefaultDevice(): Device? {
1506
+ val core = DeviceStore.store.getCategory(ObservableStore.BLUETOOTH_CATEGORY)
1507
+ val model = core["default_wearable"] as? String ?: return null
1508
+ val name = core["device_name"] as? String ?: return null
1509
+ if (model.isBlank() || name.isBlank()) return null
1510
+ val address = (core["device_address"] as? String)?.takeIf { it.isNotBlank() }
1511
+ val projectName = (core["project_name"] as? String)?.takeIf { it.isNotBlank() }
1512
+ return Device(
1513
+ model = DeviceModel.fromDeviceType(model),
1514
+ name = name,
1515
+ address = address,
1516
+ projectName = projectName,
1517
+ )
1518
+ }
1519
+
1520
+ private fun requireBluetoothReady(operation: String) {
1521
+ val bluetoothManager = appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
1522
+ val adapter =
1523
+ bluetoothManager?.adapter
1524
+ ?: throw BluetoothSdkException(
1525
+ "bluetooth_unsupported",
1526
+ "This phone does not support Bluetooth.",
1527
+ )
1528
+ val enabled =
1529
+ try {
1530
+ adapter.isEnabled
1531
+ } catch (error: SecurityException) {
1532
+ throw BluetoothSdkException(
1533
+ "bluetooth_permission_denied",
1534
+ "Allow Bluetooth permission to $operation.",
1535
+ error,
1536
+ )
1537
+ }
1538
+ if (!enabled) {
1539
+ throw BluetoothSdkException(
1540
+ "bluetooth_powered_off",
1541
+ "Turn on phone Bluetooth to $operation.",
1542
+ )
1543
+ }
1544
+ }
1545
+
1546
+ private fun Throwable.toBluetoothError(defaultCode: String): BluetoothError =
1547
+ if (this is BluetoothSdkException) {
1548
+ BluetoothError(code, message ?: code, this)
1549
+ } else {
1550
+ BluetoothError(defaultCode, message ?: toString(), this)
1551
+ }
1552
+
1553
+ private fun dispatchDiscoveredDevices(rawSearchResults: Any?) {
1554
+ val results = rawSearchResults as? List<*> ?: return
1555
+ results.forEach { rawResult ->
1556
+ val result = rawResult as? Map<*, *> ?: return@forEach
1557
+ val name = result["name"] as? String ?: return@forEach
1558
+ if (!discoveredDeviceNames.add(name)) return@forEach
1559
+ val values =
1560
+ result.entries.mapNotNull { (key, value) ->
1561
+ if (key is String && value != null) key to value else null
1562
+ }.toMap()
1563
+ val device = Device.fromMap(values) ?: return@forEach
1564
+ dispatchToListeners { it.onDeviceDiscovered(device) }
1565
+ }
1566
+ }
1567
+
1568
+ private fun dispatchBridgeEvent(eventName: String, data: Map<String, Any>) {
1569
+ when (eventName) {
1570
+ "log" -> dispatchToListeners { it.onLog(data["message"] as? String ?: data.toString()) }
1571
+ "button_press" ->
1572
+ dispatchToListeners {
1573
+ it.onButtonPress(
1574
+ ButtonPressEvent(
1575
+ buttonId = data["buttonId"] as? String ?: "",
1576
+ pressType = data["pressType"] as? String ?: "",
1577
+ timestamp = (data["timestamp"] as? Number)?.toLong(),
1578
+ )
1579
+ )
1580
+ }
1581
+ "touch_event" -> {
1582
+ val event = TouchEvent(data)
1583
+ dispatchToListeners { it.onTouch(event) }
1584
+ if (event.isSwipe) {
1585
+ dispatchToListeners { it.onSwipe(SwipeEvent(data)) }
1586
+ }
1587
+ }
1588
+ "head_up" -> dispatchToListeners { it.onHeadUpChanged(data["up"] as? Boolean ?: false) }
1589
+ "voice_activity_detection_status" ->
1590
+ dispatchToListeners {
1591
+ it.onVoiceActivityDetectionStatus(
1592
+ VoiceActivityDetectionStatusEvent(
1593
+ voiceActivityDetectionEnabled =
1594
+ data["voiceActivityDetectionEnabled"] as? Boolean
1595
+ ?: BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED,
1596
+ values = data,
1597
+ )
1598
+ )
1599
+ }
1600
+ "speaking_status" ->
1601
+ dispatchToListeners {
1602
+ it.onSpeakingStatus(
1603
+ SpeakingStatusEvent(
1604
+ speaking = data["speaking"] as? Boolean ?: false,
1605
+ values = data,
1606
+ )
1607
+ )
1608
+ }
1609
+ "battery_status" ->
1610
+ dispatchToListeners {
1611
+ it.onBatteryStatus(
1612
+ BatteryStatusEvent(
1613
+ level = (data["level"] as? Number)?.toInt(),
1614
+ charging = data["charging"] as? Boolean,
1615
+ values = data,
1616
+ )
1617
+ )
1618
+ }
1619
+ "wifi_status_change" -> {
1620
+ val event = WifiStatusEvent(data)
1621
+ handleWifiStatusForRequests(event)
1622
+ dispatchToListeners { it.onWifiStatusChanged(event) }
1623
+ }
1624
+ "wifi_scan_result" -> {
1625
+ val networks =
1626
+ (data["networks"] as? List<*>)
1627
+ ?.mapNotNull { (it as? Map<*, *>)?.stringKeyedMap() }
1628
+ ?.map(WifiScanResult::fromMap)
1629
+ ?: emptyList()
1630
+ val hasCompletionFlag =
1631
+ data.containsKey("scanComplete") || data.containsKey("scan_complete")
1632
+ val scanComplete =
1633
+ (data["scanComplete"] as? Boolean) ?: (data["scan_complete"] as? Boolean) ?: false
1634
+ val scanId = (data["scanId"] as? String)?.ifEmpty { null }
1635
+ if (scanId != null) {
1636
+ handleCorrelatedWifiScanChunk(scanId, networks, scanComplete)
1637
+ } else {
1638
+ // Old firmware: no correlation id, keep the pre-scanId heuristics.
1639
+ updateWifiScanLatestResults(networks)
1640
+ if (scanComplete || !hasCompletionFlag) {
1641
+ handleWifiScanResultsForRequests(networks)
1642
+ }
1643
+ }
1644
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1645
+ }
1646
+ "hotspot_status_change" -> {
1647
+ val event = HotspotStatusEvent(data)
1648
+ handleHotspotStatusForRequests(event)
1649
+ dispatchToListeners { it.onHotspotStatusChanged(event) }
1650
+ }
1651
+ "hotspot_error" -> {
1652
+ val event = HotspotErrorEvent(data)
1653
+ handleHotspotErrorForRequests(event)
1654
+ dispatchToListeners { it.onHotspotError(event) }
1655
+ }
1656
+ "gallery_status" -> {
1657
+ val event = GalleryStatusEvent(data)
1658
+ synchronized(oneShotLock) {
1659
+ pendingGalleryStatus?.resolve(event)
1660
+ }
1661
+ dispatchToListeners { it.onGalleryStatus(event) }
1662
+ }
1663
+ "pairing_info" -> {
1664
+ dispatchToListeners { it.onRawEvent(eventName, data) }
1665
+ }
1666
+ "photo_response" -> {
1667
+ val event = PhotoResponseEvent(data)
1668
+ handlePhotoResponseForRequests(event)
1669
+ dispatchToListeners { it.onPhotoResponse(event) }
1670
+ }
1671
+ "photo_status" -> dispatchToListeners { it.onPhotoStatus(PhotoStatusEvent(data)) }
1672
+ "camera_status" -> {
1673
+ val event = CameraStatusEvent(data)
1674
+ handleCameraStatusForRequests(event)
1675
+ dispatchToListeners { it.onCameraStatus(event) }
1676
+ }
1677
+ "video_recording_status" -> {
1678
+ val event = VideoRecordingStatusEvent(data)
1679
+ handleVideoRecordingStatusForRequests(event)
1680
+ dispatchToListeners { it.onVideoRecordingStatus(event) }
1681
+ }
1682
+ "media_success", "media_error" -> {
1683
+ val event = MediaUploadEvent(data)
1684
+ handleMediaUploadForRequests(event)
1685
+ dispatchToListeners { it.onMediaUpload(event) }
1686
+ }
1687
+ "rgb_led_control_response" -> {
1688
+ val event = RgbLedControlResponseEvent(data)
1689
+ handleRgbLedResponseForRequests(event)
1690
+ dispatchToListeners { it.onRgbLedControlResponse(event) }
1691
+ }
1692
+ "stream_status" -> {
1693
+ val event = StreamStatusEvent(data)
1694
+ handleStreamStatusForRequests(event)
1695
+ handleStreamStatusForKeepAlive(event.status)
1696
+ dispatchToListeners { it.onStreamStatus(event) }
1697
+ }
1698
+ "keep_alive_ack" -> {
1699
+ val event = KeepAliveAckEvent(data)
1700
+ if (!handleStreamKeepAliveAck(event)) {
1701
+ dispatchToListeners { it.onKeepAliveAck(event) }
1702
+ }
1703
+ }
1704
+ "ota_start_ack" -> {
1705
+ val event = OtaStartAckEvent.fromMap(data + mapOf("type" to "ota_start_ack"))
1706
+ synchronized(oneShotLock) {
1707
+ pendingOtaStart?.resolve(event)
1708
+ }
1709
+ dispatchToListeners { it.onOtaStartAck(event) }
1710
+ }
1711
+ "ota_status" -> {
1712
+ val resultValues = data + mapOf("type" to "ota_status")
1713
+ val event = OtaStatusEvent.fromMap(resultValues)
1714
+ synchronized(oneShotLock) {
1715
+ pendingOtaQuery?.resolve(OtaQueryResult(resultValues))
1716
+ otaStartRejectionErrorCode(event)?.let { errorCode ->
1717
+ pendingOtaStart?.reject(
1718
+ BluetoothSdkException(errorCode, "Glasses rejected OTA start: $errorCode")
1719
+ )
1720
+ }
1721
+ }
1722
+ dispatchToListeners { it.onOtaStatus(event) }
1723
+ }
1724
+ "mic_health" -> {
1725
+ dispatchToListeners { it.onMicHealth(MicHealthEvent.fromMap(data)) }
1726
+ }
1727
+ "settings_ack" -> {
1728
+ val event = SettingsAckEvent(data)
1729
+ handleSettingsAckForRequests(event)
1730
+ dispatchToListeners { it.onSettingsAck(event) }
1731
+ }
1732
+ "version_info" -> {
1733
+ val event = VersionInfoResult.fromMap(data)
1734
+ synchronized(oneShotLock) {
1735
+ pendingVersionInfo?.resolve(event)
1736
+ }
1737
+ dispatchToListeners { it.onVersionInfo(event) }
1738
+ }
1739
+ "mic_pcm" -> {
1740
+ val event = MicPcmEvent(data)
1741
+ if (event.pcm.isNotEmpty()) {
1742
+ dispatchToListeners { it.onMicPcm(event) }
1743
+ }
1744
+ }
1745
+ "mic_lc3" -> {
1746
+ val event = MicLc3Event(data)
1747
+ if (event.lc3.isNotEmpty()) {
1748
+ dispatchToListeners { it.onMicLc3(event) }
1749
+ }
1750
+ }
1751
+ "local_transcription" ->
1752
+ dispatchToListeners {
1753
+ it.onLocalTranscription(
1754
+ LocalTranscriptionEvent(
1755
+ text = data["text"] as? String ?: "",
1756
+ isFinal = data["isFinal"] as? Boolean ?: false,
1757
+ values = data,
1758
+ )
1759
+ )
1760
+ }
1761
+ "compatible_glasses_search_stop" ->
1762
+ dispatchToListeners { it.onScanStopped(ScanStopReason.COMPLETED) }
1763
+ "pair_failure" ->
1764
+ dispatchToListeners {
1765
+ it.onError(
1766
+ BluetoothError(
1767
+ code = "pair_failure",
1768
+ message = data["error"] as? String ?: data.toString(),
1769
+ )
1770
+ )
1771
+ }
1772
+ else -> dispatchToListeners { it.onRawEvent(eventName, data) }
1773
+ }
1774
+ }
1775
+
1776
+ private fun dispatchToListeners(callback: (BluetoothSdkListener) -> Unit) {
1777
+ val snapshot = synchronized(listeners) { listeners.toList() }
1778
+ val deliver = {
1779
+ snapshot.forEach { listener ->
1780
+ try {
1781
+ callback(listener)
1782
+ } catch (error: Throwable) {
1783
+ // Listener exceptions should not crash Bluetooth event delivery.
1784
+ }
1785
+ }
1786
+ }
1787
+ if (config.deliverCallbacksOnMainThread && Looper.myLooper() != Looper.getMainLooper()) {
1788
+ mainHandler.post(deliver)
1789
+ } else {
1790
+ deliver()
1791
+ }
1792
+ }
1793
+
1794
+ private fun startStreamKeepAliveMonitor(streamId: String, requestedIntervalSeconds: Int) {
1795
+ val intervalSeconds =
1796
+ requestedIntervalSeconds.takeIf { it > 0 } ?: DEFAULT_STREAM_KEEP_ALIVE_INTERVAL_SECONDS
1797
+ val tracker = ActiveStreamKeepAlive(
1798
+ streamId = streamId,
1799
+ intervalMs = intervalSeconds * 1_000L,
1800
+ )
1801
+ synchronized(streamKeepAliveLock) {
1802
+ activeStreamKeepAlive = tracker
1803
+ }
1804
+ sendNextStreamKeepAlive(tracker)
1805
+ }
1806
+
1807
+ private fun stopStreamKeepAliveMonitor() {
1808
+ val tracker =
1809
+ synchronized(streamKeepAliveLock) {
1810
+ val current = activeStreamKeepAlive
1811
+ activeStreamKeepAlive = null
1812
+ current
1813
+ }
1814
+ tracker?.nextTick?.let { mainHandler.removeCallbacks(it) }
1815
+ }
1816
+
1817
+ private fun handleStreamStatusForRequests(event: StreamStatusEvent) {
1818
+ val startMatch = matchingStreamStart(event)
1819
+ if (startMatch != null) {
1820
+ val (streamId, start) = startMatch
1821
+ // Preemption may only be inferred from states the glasses emit AFTER
1822
+ // start_stream has run stopAllServices() (see PREEMPTION_PROVEN_STATES).
1823
+ // ERROR must not fire it: a command-level preflight rejection (missing
1824
+ // URL, low battery, no WiFi) carries this start's streamId but is sent
1825
+ // BEFORE stopAllServices, so older starts were not preempted — and
1826
+ // rejecting one that already resolved and started its keep-alive
1827
+ // monitor would strand a live stream without keep-alives. STOPPED
1828
+ // must not fire it either: it is the stop-ack shape and proves
1829
+ // nothing about the start path having run.
1830
+ if (!event.streamId.isNullOrBlank() && event.state in PREEMPTION_PROVEN_STATES) {
1831
+ rejectPreemptedStreamStarts(start.seq)
1832
+ }
1833
+ when (event.state) {
1834
+ // `reconnected` also proves the stream is live: when an async start
1835
+ // failure triggers the glasses' publisher retry, a successful retry
1836
+ // reports `reconnected` instead of `streaming`.
1837
+ StreamState.STREAMING,
1838
+ StreamState.RECONNECTED -> {
1839
+ pendingStreamStarts.remove(streamId, start)
1840
+ start.pending.resolve(event)
1841
+ }
1842
+ StreamState.RECONNECT_FAILED,
1843
+ StreamState.STOPPED -> {
1844
+ pendingStreamStarts.remove(streamId, start)
1845
+ start.pending.reject(
1846
+ streamStatusException(start.lastError ?: event, "stream_start_failed")
1847
+ )
1848
+ }
1849
+ StreamState.ERROR -> {
1850
+ if (!event.streamId.isNullOrBlank() && event.willRetry == true) {
1851
+ // The glasses flag errors their publisher will retry with
1852
+ // `willRetry` (emitting side lands in PR #3488), so keep the
1853
+ // start pending for the retry's verdict — `reconnected` or
1854
+ // `streaming` on success, `reconnect_failed` or the
1855
+ // streamId-less `stopped` wind-down (which reports these
1856
+ // stashed details) on failure.
1857
+ start.lastError = event
1858
+ } else {
1859
+ // An id-less error is the glasses' command-level rejection
1860
+ // (missing URL, low battery, no WiFi) emitted before any
1861
+ // publisher starts; an id-carrying error without `willRetry`
1862
+ // is terminal (`camera_busy` emits an error and nothing else).
1863
+ // Old firmware never sends `willRetry`, so its errors always
1864
+ // fail fast here — the shipped pre-#3487 Android behavior.
1865
+ pendingStreamStarts.remove(streamId, start)
1866
+ start.pending.reject(streamStatusException(event, "stream_start_failed"))
1867
+ }
1868
+ }
1869
+ else -> Unit
1870
+ }
1871
+ }
1872
+
1873
+ val stop = synchronized(oneShotLock) { pendingStreamStop }
1874
+ if (stop != null && streamStatusMatches(event, stop.streamId)) {
1875
+ when {
1876
+ isAlreadyStoppedStreamStatus(event) -> {
1877
+ synchronized(oneShotLock) {
1878
+ if (pendingStreamStop === stop) {
1879
+ pendingStreamStop = null
1880
+ }
1881
+ }
1882
+ rejectStreamStartsSupersededByStop(stop.seq)
1883
+ stop.pending.resolve(stoppedStreamEvent(event, stop.streamId))
1884
+ }
1885
+ event.state == StreamState.ERROR || event.state == StreamState.RECONNECT_FAILED -> {
1886
+ synchronized(oneShotLock) {
1887
+ if (pendingStreamStop === stop) {
1888
+ pendingStreamStop = null
1889
+ }
1890
+ }
1891
+ stop.pending.reject(streamStatusException(event, "stream_stop_failed"))
1892
+ }
1893
+ else -> Unit
1894
+ }
1895
+ }
1896
+ }
1897
+
1898
+ // The glasses process BLE commands FIFO and every start_stream begins by
1899
+ // stopping whatever runs, so an id-carrying status for start X in one of
1900
+ // these states proves every lower-seq start has already been preempted.
1901
+ // Their only verdict on current firmware is a streamId-less stopped, which
1902
+ // the id-less heuristic deliberately ignores — without this they would run
1903
+ // out the 30s timeout instead of failing fast.
1904
+ private fun rejectPreemptedStreamStarts(winnerSeq: Long) {
1905
+ for ((streamId, start) in pendingStreamStarts) {
1906
+ if (start.seq < winnerSeq && pendingStreamStarts.remove(streamId, start)) {
1907
+ start.pending.reject(
1908
+ BluetoothSdkException(
1909
+ "stream_preempted",
1910
+ "start stream $streamId was preempted by a newer start_stream; " +
1911
+ "the glasses run a single stream and the last request wins.",
1912
+ )
1913
+ )
1914
+ }
1915
+ }
1916
+ }
1917
+
1918
+ // The glasses process BLE commands FIFO, so this stop's ack also settles
1919
+ // every start sent before it: whatever those starts brought up (or would
1920
+ // have brought up) has been stopped, and no further status will arrive
1921
+ // for them. Without this they would run out the 30s start timeout.
1922
+ // Starts sent after the stop keep waiting for their own statuses.
1923
+ private fun rejectStreamStartsSupersededByStop(stopSeq: Long) {
1924
+ for ((streamId, start) in pendingStreamStarts) {
1925
+ if (start.seq < stopSeq && pendingStreamStarts.remove(streamId, start)) {
1926
+ start.pending.reject(
1927
+ BluetoothSdkException(
1928
+ "stream_stopped",
1929
+ "start stream $streamId was superseded by a stop_stream issued after it.",
1930
+ )
1931
+ )
1932
+ }
1933
+ }
1934
+ }
1935
+
1936
+ private fun matchingStreamStart(event: StreamStatusEvent): Pair<String, PendingStreamStart>? {
1937
+ val streamId = event.streamId
1938
+ if (!streamId.isNullOrBlank()) {
1939
+ val start = pendingStreamStarts[streamId] ?: return null
1940
+ return streamId to start
1941
+ }
1942
+ if (event.state == StreamState.ERROR) {
1943
+ // An id-less error is a command-level preflight rejection (missing
1944
+ // URL, low battery, no WiFi) emitted synchronously, while lifecycle
1945
+ // statuses arrive async — so with overlapping starts the wire is
1946
+ // genuinely ambiguous about which start it answers. Attribute it to
1947
+ // the newest pending start: the common case is a later start
1948
+ // failing preflight while an earlier one is already emitting
1949
+ // lifecycle statuses, and at worst this swaps which start carries
1950
+ // the error instead of letting both time out.
1951
+ val entry = pendingStreamStarts.entries.maxByOrNull { it.value.seq } ?: return null
1952
+ return entry.key to entry.value
1953
+ }
1954
+ if (pendingStreamStarts.size == 1) {
1955
+ // firstOrNull: timeouts and other handler threads mutate the
1956
+ // ConcurrentHashMap concurrently, so the map can empty between the
1957
+ // size check and this read; bail out instead of throwing.
1958
+ val entry = pendingStreamStarts.entries.firstOrNull() ?: return null
1959
+ // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1960
+ // stream (their stop ack carries no streamId), not a verdict on the
1961
+ // pending start — a start_stream that replaces a running publisher
1962
+ // emits exactly this sequence (stopped -> initializing -> streaming).
1963
+ // Attributing it here would reject a start that is about to succeed.
1964
+ // After a stashed `willRetry` error for the pending start, though,
1965
+ // the stopped is the retrying publisher's wind-down (the stop-ack
1966
+ // always precedes any status for the new start), so it is that
1967
+ // start's verdict.
1968
+ if (event.state == StreamState.STOPPED && entry.value.lastError == null) {
1969
+ return null
1970
+ }
1971
+ return entry.key to entry.value
1972
+ }
1973
+ return null
1974
+ }
1975
+
1976
+ private fun streamStatusMatches(event: StreamStatusEvent, streamId: String?): Boolean =
1977
+ streamId.isNullOrBlank() || event.streamId.isNullOrBlank() || event.streamId == streamId
1978
+
1979
+ private fun isAlreadyStoppedStreamStatus(event: StreamStatusEvent): Boolean {
1980
+ if (event.state == StreamState.STOPPED) {
1981
+ return true
1982
+ }
1983
+ val details = (event.status as? StreamStatus.Error)?.errorDetails?.lowercase()
1984
+ return details in setOf("not_streaming", "already_stopped", "not streaming")
1985
+ }
1986
+
1987
+ private fun stoppedStreamEvent(event: StreamStatusEvent, streamId: String?): StreamStatusEvent =
1988
+ StreamStatusEvent(
1989
+ StreamStatus.Lifecycle(
1990
+ state = StreamState.STOPPED,
1991
+ streamId = event.streamId ?: streamId,
1992
+ timestamp = event.status.timestamp ?: System.currentTimeMillis(),
1993
+ resolvedConfig = event.resolvedConfig,
1994
+ )
1995
+ )
1996
+
1997
+ private fun streamStatusException(event: StreamStatusEvent, code: String): BluetoothSdkException {
1998
+ val details = (event.status as? StreamStatus.Error)?.errorDetails
1999
+ ?: "Stream status ${event.state.value}"
2000
+ return BluetoothSdkException(code, details)
2001
+ }
2002
+
2003
+ private fun handlePhotoResponseForRequests(event: PhotoResponseEvent) {
2004
+ val pending = pendingPhotoRequests[event.requestId] ?: return
2005
+ when (val response = event.response) {
2006
+ is PhotoResponse.Success -> pending.resolve(event)
2007
+ is PhotoResponse.Error ->
2008
+ pending.reject(
2009
+ BluetoothSdkException(
2010
+ response.errorCode ?: "photo_request_failed",
2011
+ response.errorMessage,
2012
+ )
2013
+ )
2014
+ }
2015
+ }
2016
+
2017
+ private fun handleCameraStatusForRequests(event: CameraStatusEvent) {
2018
+ val pending = pendingCameraStatusRequests[event.requestId] ?: return
2019
+ when (event.state.lowercase()) {
2020
+ "ready" -> pending.resolve(event)
2021
+ "error" ->
2022
+ pending.reject(
2023
+ BluetoothSdkException(
2024
+ event.errorCode ?: "camera_warm_up_failed",
2025
+ event.errorMessage ?: "Camera warm-up failed.",
2026
+ )
2027
+ )
2028
+ // "warming"/"stopped" are progress updates; leave the pending promise alone.
2029
+ else -> {}
2030
+ }
2031
+ }
2032
+
2033
+ private fun handleVideoRecordingStatusForRequests(event: VideoRecordingStatusEvent) {
2034
+ val request = pendingVideoRecordingRequests[event.requestId] ?: return
2035
+ if (event.success) {
2036
+ if (event.status == request.expectedStatus) {
2037
+ if (request.waitForUpload) {
2038
+ request.stoppedEvent = event
2039
+ if (request.uploadSucceeded) {
2040
+ request.pending.resolve(event)
2041
+ }
2042
+ } else {
2043
+ request.pending.resolve(event)
2044
+ }
2045
+ }
2046
+ } else {
2047
+ request.pending.reject(
2048
+ BluetoothSdkException(
2049
+ event.status.ifBlank { "video_recording_failed" },
2050
+ event.details ?: "Video recording command failed.",
2051
+ )
2052
+ )
2053
+ }
2054
+ }
2055
+
2056
+ private fun handleMediaUploadForRequests(event: MediaUploadEvent) {
2057
+ if (!event.isVideo) return
2058
+ val request = pendingVideoRecordingRequests[event.requestId] ?: return
2059
+ if (!request.waitForUpload) return
2060
+ if (event.isSuccess) {
2061
+ val stoppedEvent = request.stoppedEvent
2062
+ if (stoppedEvent != null) {
2063
+ request.pending.resolve(stoppedEvent)
2064
+ } else {
2065
+ request.uploadSucceeded = true
2066
+ }
2067
+ } else {
2068
+ request.pending.reject(
2069
+ BluetoothSdkException(
2070
+ "video_upload_failed",
2071
+ event.errorMessage ?: "Video upload failed.",
2072
+ )
2073
+ )
2074
+ }
2075
+ }
2076
+
2077
+ private fun handleRgbLedResponseForRequests(event: RgbLedControlResponseEvent) {
2078
+ val pending = pendingRgbLedRequests[event.requestId] ?: return
2079
+ if (event.state == "success") {
2080
+ pending.resolve(event)
2081
+ } else {
2082
+ pending.reject(
2083
+ BluetoothSdkException(
2084
+ event.errorCode ?: "rgb_led_control_failed",
2085
+ event.errorCode ?: "RGB LED command failed.",
2086
+ )
2087
+ )
2088
+ }
2089
+ }
2090
+
2091
+ private fun handleSettingsAckForRequests(event: SettingsAckEvent) {
2092
+ val pending = pendingSettingsRequests[event.requestId] ?: return
2093
+ if (isFailureStatus(event.status)) {
2094
+ pending.reject(
2095
+ BluetoothSdkException(
2096
+ event.errorCode ?: "${event.setting.ifBlank { "settings" }}_failed",
2097
+ event.errorMessage ?: "Settings command ${event.setting.ifBlank { event.requestId }} failed.",
2098
+ )
2099
+ )
2100
+ } else {
2101
+ pending.resolve(event)
2102
+ }
2103
+ }
2104
+
2105
+ private fun isFailureStatus(status: String): Boolean =
2106
+ status.lowercase() in setOf("error", "failed", "failure", "rejected")
2107
+
2108
+ private fun handleWifiScanResultsForRequests(results: List<WifiScanResult>) {
2109
+ val request = synchronized(oneShotLock) { pendingWifiScan } ?: return
2110
+ synchronized(oneShotLock) {
2111
+ if (pendingWifiScan === request) {
2112
+ pendingWifiScan = null
2113
+ }
2114
+ }
2115
+ request.pending.resolve(results)
2116
+ }
2117
+
2118
+ // scanId-echoing glasses: results for another scan are ignored instead of
2119
+ // resolving the pending request, and matching chunks accumulate until the
2120
+ // glasses flag the scan complete.
2121
+ private fun handleCorrelatedWifiScanChunk(
2122
+ scanId: String,
2123
+ results: List<WifiScanResult>,
2124
+ scanComplete: Boolean,
2125
+ ) {
2126
+ val request: PendingWifiScan
2127
+ val accumulated: List<WifiScanResult>
2128
+ synchronized(oneShotLock) {
2129
+ request = pendingWifiScan?.takeIf { it.scanId == scanId } ?: return
2130
+ for (network in results) {
2131
+ if (request.accumulated.none { it.ssid == network.ssid }) {
2132
+ request.accumulated.add(network)
2133
+ }
2134
+ }
2135
+ accumulated = request.accumulated.toList()
2136
+ if (accumulated.isNotEmpty()) {
2137
+ request.latestResults = accumulated
2138
+ }
2139
+ if (!scanComplete) return
2140
+ if (pendingWifiScan === request) {
2141
+ pendingWifiScan = null
2142
+ }
2143
+ }
2144
+ request.pending.resolve(accumulated)
2145
+ }
2146
+
2147
+ private fun updateWifiScanLatestResults(results: List<WifiScanResult>) {
2148
+ if (results.isEmpty()) return
2149
+ synchronized(oneShotLock) {
2150
+ pendingWifiScan?.latestResults = results
2151
+ }
2152
+ }
2153
+
2154
+ private fun handleWifiStatusForRequests(event: WifiStatusEvent) {
2155
+ val request = synchronized(oneShotLock) { pendingWifiStatus } ?: return
2156
+ // A wifi_status carrying the explicit error field is the glasses' failure
2157
+ // verdict for the in-flight connect: reject now instead of running out the
2158
+ // request timeout. Only the error field counts as failure — the glasses'
2159
+ // connect sequence emits a debounced bare connected=false ~1-2s after
2160
+ // credentials while association is still in progress, and rejecting on that
2161
+ // would kill every connect attempt early.
2162
+ if (request.operation == WifiStatusOperation.CONNECT && event.error != null) {
2163
+ synchronized(oneShotLock) {
2164
+ if (pendingWifiStatus === request) {
2165
+ pendingWifiStatus = null
2166
+ }
2167
+ }
2168
+ request.pending.reject(
2169
+ BluetoothSdkException(
2170
+ event.error,
2171
+ "Glasses failed to join \"${request.ssid}\": ${event.error}",
2172
+ )
2173
+ )
2174
+ return
2175
+ }
2176
+ if (!wifiStatusMatches(event.status, request)) return
2177
+ synchronized(oneShotLock) {
2178
+ if (pendingWifiStatus === request) {
2179
+ pendingWifiStatus = null
2180
+ }
2181
+ }
2182
+ request.pending.resolve(event)
2183
+ }
2184
+
2185
+ private fun wifiStatusMatches(status: WifiStatus, request: PendingWifiStatusRequest): Boolean =
2186
+ when (request.operation) {
2187
+ WifiStatusOperation.CONNECT ->
2188
+ status is WifiStatus.Connected && status.ssid == request.ssid
2189
+ WifiStatusOperation.FORGET ->
2190
+ status == WifiStatus.Disconnected || (status is WifiStatus.Connected && status.ssid != request.ssid)
2191
+ }
2192
+
2193
+ private fun handleHotspotStatusForRequests(event: HotspotStatusEvent) {
2194
+ val request = synchronized(oneShotLock) { pendingHotspotStatus } ?: return
2195
+ if (!hotspotStatusMatches(event.status, request.enabled)) return
2196
+ synchronized(oneShotLock) {
2197
+ if (pendingHotspotStatus === request) {
2198
+ pendingHotspotStatus = null
2199
+ }
2200
+ }
2201
+ request.pending.resolve(event)
2202
+ }
2203
+
2204
+ private fun hotspotStatusMatches(status: HotspotStatus, enabled: Boolean): Boolean =
2205
+ if (enabled) {
2206
+ status is HotspotStatus.Enabled
2207
+ } else {
2208
+ status == HotspotStatus.Disabled
2209
+ }
2210
+
2211
+ private fun handleHotspotErrorForRequests(event: HotspotErrorEvent) {
2212
+ val request = synchronized(oneShotLock) { pendingHotspotStatus } ?: return
2213
+ synchronized(oneShotLock) {
2214
+ if (pendingHotspotStatus === request) {
2215
+ pendingHotspotStatus = null
2216
+ }
2217
+ }
2218
+ request.pending.reject(
2219
+ BluetoothSdkException(
2220
+ "hotspot_command_failed",
2221
+ event.message ?: "Hotspot command failed.",
2222
+ )
2223
+ )
2224
+ }
2225
+
2226
+ private fun sendNextStreamKeepAlive(tracker: ActiveStreamKeepAlive) {
2227
+ var timeoutEvent: StreamStatusEvent? = null
2228
+ var request: StreamKeepAliveRequest? = null
2229
+
2230
+ synchronized(streamKeepAliveLock) {
2231
+ if (activeStreamKeepAlive !== tracker) {
2232
+ return
2233
+ }
2234
+
2235
+ if (tracker.armed && tracker.pendingAckId != null) {
2236
+ tracker.missedAckCount += 1
2237
+ if (tracker.missedAckCount >= MAX_MISSED_STREAM_KEEP_ALIVE_ACKS) {
2238
+ activeStreamKeepAlive = null
2239
+ tracker.nextTick?.let { mainHandler.removeCallbacks(it) }
2240
+ timeoutEvent =
2241
+ StreamStatusEvent(
2242
+ StreamStatus.Error(
2243
+ streamId = tracker.streamId,
2244
+ errorDetails =
2245
+ "Stream keep-alive timed out after ${tracker.missedAckCount} missed ACKs",
2246
+ timestamp = System.currentTimeMillis(),
2247
+ resolvedConfig = null,
2248
+ )
2249
+ )
2250
+ return@synchronized
2251
+ }
2252
+ }
2253
+
2254
+ val ackId = "ack-${System.currentTimeMillis()}"
2255
+ tracker.pendingAckId = ackId
2256
+ request = StreamKeepAliveRequest(streamId = tracker.streamId, ackId = ackId)
2257
+ val nextTick = Runnable { sendNextStreamKeepAlive(tracker) }
2258
+ tracker.nextTick = nextTick
2259
+ mainHandler.postDelayed(nextTick, tracker.intervalMs)
2260
+ }
2261
+
2262
+ timeoutEvent?.let { event ->
2263
+ dispatchToListeners { it.onStreamStatus(event) }
2264
+ stopStreamKeepAliveMonitor()
2265
+ deviceManager.stopStream()
2266
+ return
2267
+ }
2268
+
2269
+ request?.let { keepAlive ->
2270
+ deviceManager.keepStreamAlive(keepAlive.toMap().toMutableMap())
2271
+ }
2272
+ }
2273
+
2274
+ private fun handleStreamKeepAliveAck(event: KeepAliveAckEvent): Boolean {
2275
+ synchronized(streamKeepAliveLock) {
2276
+ val tracker = activeStreamKeepAlive ?: return false
2277
+ if (event.streamId != tracker.streamId || event.ackId != tracker.pendingAckId) {
2278
+ return false
2279
+ }
2280
+ tracker.pendingAckId = null
2281
+ tracker.missedAckCount = 0
2282
+ return true
2283
+ }
2284
+ }
2285
+
2286
+ private fun handleStreamStatusForKeepAlive(status: StreamStatus) {
2287
+ val streamId = status.streamId
2288
+ val activeStreamId = synchronized(streamKeepAliveLock) { activeStreamKeepAlive?.streamId }
2289
+ if (streamId == null || activeStreamId != streamId) {
2290
+ return
2291
+ }
2292
+ when (status.state) {
2293
+ StreamState.STOPPED,
2294
+ StreamState.STOPPING,
2295
+ StreamState.ERROR,
2296
+ StreamState.RECONNECT_FAILED -> stopStreamKeepAliveMonitor()
2297
+ // A non-terminal status means the stream is live or coming up and the glasses can
2298
+ // now ACK; arm the missed-ACK detector from here so a slow startup before the first
2299
+ // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
2300
+ // pre-arm bookkeeping so a stale unacked id (sent before the glasses could ACK)
2301
+ // can't immediately count as a miss.
2302
+ else ->
2303
+ synchronized(streamKeepAliveLock) {
2304
+ activeStreamKeepAlive?.let {
2305
+ if (it.streamId == streamId && !it.armed) {
2306
+ it.armed = true
2307
+ it.pendingAckId = null
2308
+ it.missedAckCount = 0
2309
+ }
2310
+ }
2311
+ }
2312
+ }
2313
+ }
2314
+ }
2315
+
2316
+ /** OTA status messages are not request-correlated, so only known pre-ack failures settle a start. */
2317
+ internal fun otaStartRejectionErrorCode(event: OtaStatusEvent): String? =
2318
+ if (
2319
+ event.status.equals("failed", ignoreCase = true) &&
2320
+ event.errorMessage.equals("battery_low", ignoreCase = true)
2321
+ ) {
2322
+ "battery_low"
2323
+ } else {
2324
+ null
2325
+ }