@mentra/bluetooth-sdk 0.1.0

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 (278) hide show
  1. package/README.md +278 -0
  2. package/android/build.gradle +167 -0
  3. package/android/lc3Lib/CMakeLists.txt +6 -0
  4. package/android/lc3Lib/build.gradle +109 -0
  5. package/android/lc3Lib/proguard-rules.pro +33 -0
  6. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  7. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  8. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  26. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  81. package/android/lc3Lib/src/main/java/com/mentra/lc3Lib/Lc3Cpp.java +38 -0
  82. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  83. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  86. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  119. package/android/settings.gradle +1 -0
  120. package/android/src/main/AndroidManifest.xml +40 -0
  121. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +528 -0
  122. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +610 -0
  123. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1616 -0
  124. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +314 -0
  125. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothModels.kt +1710 -0
  126. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +554 -0
  127. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +68 -0
  128. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  129. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +934 -0
  130. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +175 -0
  131. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +1109 -0
  132. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +3974 -0
  133. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3551 -0
  134. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1327 -0
  135. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +6959 -0
  136. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1655 -0
  137. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +53675 -0
  138. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +202 -0
  139. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +224 -0
  140. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +340 -0
  141. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +465 -0
  142. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +221 -0
  143. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AES.java +106 -0
  144. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  145. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  146. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +179 -0
  147. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +44 -0
  148. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G1Text.kt +436 -0
  149. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  150. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +76 -0
  151. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +791 -0
  152. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +186 -0
  153. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +589 -0
  154. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +303 -0
  155. package/android/src/main/java/com/mentra/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  156. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  157. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  158. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  159. package/android/src/main/res/values/strings.xml +4 -0
  160. package/app.plugin.js +1 -0
  161. package/build/BluetoothSdk.types.d.ts +498 -0
  162. package/build/BluetoothSdk.types.d.ts.map +1 -0
  163. package/build/BluetoothSdk.types.js +23 -0
  164. package/build/BluetoothSdk.types.js.map +1 -0
  165. package/build/BluetoothSdkModule.d.ts +71 -0
  166. package/build/BluetoothSdkModule.d.ts.map +1 -0
  167. package/build/BluetoothSdkModule.js +106 -0
  168. package/build/BluetoothSdkModule.js.map +1 -0
  169. package/build/index.d.ts +3 -0
  170. package/build/index.d.ts.map +1 -0
  171. package/build/index.js +5 -0
  172. package/build/index.js.map +1 -0
  173. package/expo-module.config.json +9 -0
  174. package/ios/BluetoothSdkModule.swift +665 -0
  175. package/ios/MentraBluetoothSDK.podspec +77 -0
  176. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  177. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  178. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  179. package/ios/Packages/CoreObjC/attdet.c +92 -0
  180. package/ios/Packages/CoreObjC/attdet.h +44 -0
  181. package/ios/Packages/CoreObjC/bits.c +375 -0
  182. package/ios/Packages/CoreObjC/bits.h +315 -0
  183. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  184. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  185. package/ios/Packages/CoreObjC/common.h +151 -0
  186. package/ios/Packages/CoreObjC/energy.c +70 -0
  187. package/ios/Packages/CoreObjC/energy.h +43 -0
  188. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  189. package/ios/Packages/CoreObjC/lc3.c +704 -0
  190. package/ios/Packages/CoreObjC/lc3.h +313 -0
  191. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  192. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  193. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  194. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  195. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  196. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  197. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  198. package/ios/Packages/CoreObjC/mdct.c +469 -0
  199. package/ios/Packages/CoreObjC/mdct.h +57 -0
  200. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  201. package/ios/Packages/CoreObjC/meson.build +61 -0
  202. package/ios/Packages/CoreObjC/plc.c +61 -0
  203. package/ios/Packages/CoreObjC/plc.h +57 -0
  204. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  205. package/ios/Packages/CoreObjC/sns.c +880 -0
  206. package/ios/Packages/CoreObjC/sns.h +103 -0
  207. package/ios/Packages/CoreObjC/spec.c +907 -0
  208. package/ios/Packages/CoreObjC/spec.h +119 -0
  209. package/ios/Packages/CoreObjC/tables.c +3457 -0
  210. package/ios/Packages/CoreObjC/tables.h +94 -0
  211. package/ios/Packages/CoreObjC/tns.c +457 -0
  212. package/ios/Packages/CoreObjC/tns.h +99 -0
  213. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  214. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  215. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +1659 -0
  216. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +162 -0
  217. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +1852 -0
  218. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +674 -0
  219. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +44 -0
  220. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  221. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  222. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  223. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  224. package/ios/Packages/VAD/Common/VAD/VADQuality.swift +41 -0
  225. package/ios/Packages/VAD/Common/VAD/VADState.swift +26 -0
  226. package/ios/Packages/VAD/Common/VAD/VADStrategy.swift +29 -0
  227. package/ios/Packages/VAD/Common/VAD/VADType.swift +14 -0
  228. package/ios/Packages/VAD/Data/Configuration.swift +68 -0
  229. package/ios/Packages/VAD/Data/FrameSize.swift +39 -0
  230. package/ios/Packages/VAD/Data/Record.swift +13 -0
  231. package/ios/Packages/VAD/Data/Result.swift +22 -0
  232. package/ios/Packages/VAD/Data/SampleRate.swift +48 -0
  233. package/ios/Packages/VAD/Silero/Model/silero_vad.onnx +0 -0
  234. package/ios/Packages/VAD/Silero/SileroVAD.swift +284 -0
  235. package/ios/Packages/VAD/Silero/SileroVADStrategy.swift +64 -0
  236. package/ios/Packages/libbz2/module.modulemap +5 -0
  237. package/ios/Packages/libbz2/shim.h +1 -0
  238. package/ios/Source/Bridge.swift +353 -0
  239. package/ios/Source/Bridging-Header.h +16 -0
  240. package/ios/Source/DeviceManager.swift +1478 -0
  241. package/ios/Source/DeviceStore.swift +295 -0
  242. package/ios/Source/MentraBluetoothSDK.swift +2439 -0
  243. package/ios/Source/ObservableStore.swift +88 -0
  244. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  245. package/ios/Source/controllers/ControllerManager.swift +187 -0
  246. package/ios/Source/controllers/R1.swift +827 -0
  247. package/ios/Source/services/PhoneMic.swift +662 -0
  248. package/ios/Source/sgcs/Frame.swift +651 -0
  249. package/ios/Source/sgcs/G1.swift +2505 -0
  250. package/ios/Source/sgcs/G2.swift +3730 -0
  251. package/ios/Source/sgcs/Mach1.swift +543 -0
  252. package/ios/Source/sgcs/MentraLive.swift +4708 -0
  253. package/ios/Source/sgcs/MentraNex.swift +2479 -0
  254. package/ios/Source/sgcs/SGCManager.swift +243 -0
  255. package/ios/Source/sgcs/Simulated.swift +270 -0
  256. package/ios/Source/sgcs/mentraos_ble.pb.swift +3811 -0
  257. package/ios/Source/stt/STTTools.swift +154 -0
  258. package/ios/Source/stt/SherpaOnnxTranscriber.swift +401 -0
  259. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  260. package/ios/Source/utils/Constants.swift +74 -0
  261. package/ios/Source/utils/Enums.swift +95 -0
  262. package/ios/Source/utils/G1Text.swift +2067 -0
  263. package/ios/Source/utils/JSCExperiment.swift +241 -0
  264. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  265. package/ios/Source/utils/MessageChunker.swift +164 -0
  266. package/ios/Source/utils/Models.swift +135 -0
  267. package/ios/Source/utils/PhoneAudioMonitor.swift +226 -0
  268. package/ios/Source/utils/TarBz2Extractor.swift +206 -0
  269. package/package.json +78 -0
  270. package/plugin/build/index.d.ts +6 -0
  271. package/plugin/build/index.js +12 -0
  272. package/plugin/build/withAndroid.d.ts +4 -0
  273. package/plugin/build/withAndroid.js +80 -0
  274. package/plugin/build/withIos.d.ts +4 -0
  275. package/plugin/build/withIos.js +64 -0
  276. package/src/BluetoothSdk.types.ts +581 -0
  277. package/src/BluetoothSdkModule.ts +259 -0
  278. package/src/index.ts +4 -0
@@ -0,0 +1,3551 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCallback
6
+ import android.bluetooth.BluetoothGattCharacteristic
7
+ import android.bluetooth.BluetoothGattDescriptor
8
+ import android.bluetooth.BluetoothProfile
9
+ import android.bluetooth.le.ScanCallback
10
+ import android.bluetooth.le.ScanResult
11
+ import android.bluetooth.le.ScanSettings
12
+ import android.content.Context
13
+ import android.graphics.Bitmap
14
+ import android.graphics.BitmapFactory
15
+ import android.graphics.Canvas
16
+ import android.graphics.Color
17
+ import android.graphics.Paint
18
+ import android.graphics.Rect
19
+ import android.os.Handler
20
+ import android.os.Looper
21
+ import android.util.Base64
22
+ import com.mentra.bluetoothsdk.Bridge
23
+ import com.mentra.bluetoothsdk.DeviceManager
24
+ import com.mentra.bluetoothsdk.DeviceStore
25
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
26
+ import java.io.ByteArrayOutputStream
27
+ import java.util.TimeZone
28
+ import java.util.UUID
29
+ import java.util.regex.Pattern
30
+
31
+ // ---------- G2 Protocol Constants ----------
32
+
33
+ private object G2BLE {
34
+ // EvenHub BLE characteristic UUIDs (NOT the G1 UART UUIDs!)
35
+ val CHAR_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5401")
36
+ val CHAR_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5402")
37
+ val AUDIO_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E6402")
38
+ val SERVICE_UUID: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E0000")
39
+ val CLIENT_CHARACTERISTIC_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
40
+
41
+ const val HEADER_BYTE: Byte = 0xAA.toByte()
42
+ const val SOURCE_PHONE: Byte = 1
43
+ const val DEST_GLASSES: Byte = 2
44
+ const val MAX_PACKET_PAYLOAD: Int = 236
45
+ }
46
+
47
+ // Service IDs from service_id_def.proto
48
+ private enum class ServiceID(val value: Byte) {
49
+ DASHBOARD(0x01), // UI_BACKGROUND_DASHBOARD_APP_ID
50
+ MENU(0x03), // UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
51
+ EVEN_AI(0x07), // UI_FOREGROUND_EVEN_AI_ID
52
+ G2_SETTING(0x09), // UI_SETTING_APP_ID
53
+ GESTURE_CTRL(0x0D), // gesture_ctrl lifecycle signals
54
+ ONBOARDING(0x10), // UI_ONBOARDING_APP_ID
55
+ DEVICE_SETTINGS(0x80.toByte()), // UX_DEVICE_SETTINGS_APP_ID
56
+ EVEN_HUB_CTRL(0x81.toByte()), // EvenHub CTRL channel (init/registration)
57
+ EVEN_HUB(0xE0.toByte()); // UI_BACKGROUND_EVENHUB_APP_ID
58
+
59
+ companion object {
60
+ fun fromByte(b: Byte): ServiceID? = entries.find { it.value == b }
61
+ }
62
+ }
63
+
64
+ // EvenHub command IDs from EvenHub.proto
65
+ private enum class EvenHubCmd(val value: Int) {
66
+ CREATE_STARTUP_PAGE(0),
67
+ UPDATE_IMAGE_RAW_DATA(3),
68
+ UPDATE_TEXT_DATA(5),
69
+ REBUILD_PAGE(7),
70
+ SHUTDOWN_PAGE(9),
71
+ HEARTBEAT(12),
72
+ AUDIO_CONTROL(15)
73
+ }
74
+
75
+ // EvenHub response command IDs (glasses → phone)
76
+ private enum class EvenHubResponseCmd(val value: Int) {
77
+ OS_NOTIFY_EVENT_TO_APP(2)
78
+ }
79
+
80
+ // OsEventTypeList from EvenHub.proto
81
+ private enum class OsEventType(val value: Int) {
82
+ CLICK(0),
83
+ SCROLL_TOP(1),
84
+ SCROLL_BOTTOM(2),
85
+ DOUBLE_CLICK(3),
86
+ FOREGROUND_ENTER(4),
87
+ FOREGROUND_EXIT(5),
88
+ ABNORMAL_EXIT(6),
89
+ SYSTEM_EXIT(7);
90
+
91
+ companion object {
92
+ fun fromInt(v: Int): OsEventType? = entries.find { it.value == v }
93
+ }
94
+ }
95
+
96
+ // g2_settingCommandId from g2_setting.proto
97
+ private enum class G2SettingCommandId(val value: Int) {
98
+ NONE(0),
99
+ DEVICE_RECEIVE_INFO(1),
100
+ DEVICE_RECEIVE_REQUEST(2),
101
+ DEVICE_SEND_TO_APP(3),
102
+ DEVICE_RESPOND_TO_APP(4)
103
+ }
104
+
105
+ // DevCfgCommandId from dev_config_protocol.proto
106
+ private enum class DevCfgCommandId(val value: Int) {
107
+ AUTHENTICATION(4),
108
+ PIPE_ROLE_CHANGE(5),
109
+ RING_CONNECT_INFO(6),
110
+ TIME_SYNC(128),
111
+ BASE_CONN_HEART_BEAT(14)
112
+ }
113
+
114
+ // ---------- CRC16 ----------
115
+
116
+ private fun calcCRC16(data: ByteArray): Int {
117
+ var crc = 0xFFFF
118
+ for (byte in data) {
119
+ val b = byte.toInt() and 0xFF
120
+ crc = ((crc shr 8) or ((crc shl 8) and 0xFF00)) xor b
121
+ crc = crc xor ((crc and 0xFF) shr 4)
122
+ crc = crc xor ((crc shl 12) and 0xFFFF)
123
+ crc = crc xor (((crc and 0xFF) shl 5) and 0xFFFF)
124
+ }
125
+ return crc and 0xFFFF
126
+ }
127
+
128
+ // ---------- Minimal Protobuf Encoding ----------
129
+
130
+ private class ProtobufWriter {
131
+ private val stream = ByteArrayOutputStream()
132
+
133
+ fun writeVarint(value: Long) {
134
+ var v = value
135
+ // Use unsigned comparison so negative values (sign-extended) produce 10-byte varints
136
+ while (v.toULong() > 0x7FuL) {
137
+ stream.write(((v and 0x7F) or 0x80).toInt())
138
+ v = v ushr 7
139
+ }
140
+ stream.write((v and 0x7F).toInt())
141
+ }
142
+
143
+ fun writeInt32Field(fieldNumber: Int, value: Int) {
144
+ val tag = (fieldNumber shl 3).toLong() // wire type 0 = varint
145
+ writeVarint(tag)
146
+ // Kotlin Int.toLong() sign-extends, which is correct for protobuf int32
147
+ // Negative values produce 10-byte varints via unsigned comparison in writeVarint
148
+ writeVarint(value.toLong())
149
+ }
150
+
151
+ fun writeStringField(fieldNumber: Int, value: String) {
152
+ val tag = ((fieldNumber shl 3) or 2).toLong() // wire type 2 = length-delimited
153
+ writeVarint(tag)
154
+ val utf8 = value.toByteArray(Charsets.UTF_8)
155
+ writeVarint(utf8.size.toLong())
156
+ stream.write(utf8)
157
+ }
158
+
159
+ fun writeBytesField(fieldNumber: Int, value: ByteArray) {
160
+ val tag = ((fieldNumber shl 3) or 2).toLong()
161
+ writeVarint(tag)
162
+ writeVarint(value.size.toLong())
163
+ stream.write(value)
164
+ }
165
+
166
+ fun writeMessageField(fieldNumber: Int, subMessage: ByteArray) {
167
+ val tag = ((fieldNumber shl 3) or 2).toLong()
168
+ writeVarint(tag)
169
+ writeVarint(subMessage.size.toLong())
170
+ stream.write(subMessage)
171
+ }
172
+
173
+ fun writeBoolField(fieldNumber: Int, value: Boolean) {
174
+ writeInt32Field(fieldNumber, if (value) 1 else 0)
175
+ }
176
+
177
+ fun toByteArray(): ByteArray = stream.toByteArray()
178
+ }
179
+
180
+ // ---------- Minimal Protobuf Decoding ----------
181
+
182
+ private class ProtobufReader(private val data: ByteArray) {
183
+ private var offset: Int = 0
184
+
185
+ val hasMore: Boolean
186
+ get() = offset < data.size
187
+
188
+ fun readVarint(): Long? {
189
+ var result: Long = 0
190
+ var shift = 0
191
+ while (offset < data.size) {
192
+ val byte = data[offset].toInt() and 0xFF
193
+ offset++
194
+ result = result or ((byte.toLong() and 0x7F) shl shift)
195
+ if (byte and 0x80 == 0) return result
196
+ shift += 7
197
+ if (shift > 63) return null
198
+ }
199
+ return null
200
+ }
201
+
202
+ fun readTag(): Pair<Int, Int>? {
203
+ val tag = readVarint() ?: return null
204
+ return Pair((tag shr 3).toInt(), (tag and 0x07).toInt())
205
+ }
206
+
207
+ fun readInt32(): Int? {
208
+ val v = readVarint() ?: return null
209
+ return v.toInt()
210
+ }
211
+
212
+ fun readBytes(): ByteArray? {
213
+ val len = readVarint()?.toInt() ?: return null
214
+ if (offset + len > data.size) return null
215
+ val result = data.copyOfRange(offset, offset + len)
216
+ offset += len
217
+ return result
218
+ }
219
+
220
+ fun readString(): String? {
221
+ val bytes = readBytes() ?: return null
222
+ return String(bytes, Charsets.UTF_8)
223
+ }
224
+
225
+ fun skipField(wireType: Int) {
226
+ when (wireType) {
227
+ 0 -> readVarint() // varint
228
+ 1 -> offset += 8 // 64-bit
229
+ 2 -> readBytes() // length-delimited
230
+ 5 -> offset += 4 // 32-bit
231
+ }
232
+ }
233
+
234
+ fun parseFields(): Map<Int, Any> {
235
+ val fields = mutableMapOf<Int, Any>()
236
+ while (hasMore) {
237
+ val (fieldNum, wireType) = readTag() ?: break
238
+ when (wireType) {
239
+ 0 -> {
240
+ val v = readVarint()
241
+ if (v != null) fields[fieldNum] = v.toInt()
242
+ }
243
+ 2 -> {
244
+ val d = readBytes()
245
+ if (d != null) fields[fieldNum] = d
246
+ }
247
+ else -> skipField(wireType)
248
+ }
249
+ }
250
+ return fields
251
+ }
252
+ }
253
+
254
+ // ---------- EvenHub Protobuf Message Builders ----------
255
+
256
+ private object EvenHubProto {
257
+ fun textContainerProperty(
258
+ x: Int,
259
+ y: Int,
260
+ width: Int,
261
+ height: Int,
262
+ borderWidth: Int = 0,
263
+ borderColor: Int = 0,
264
+ borderRadius: Int = 0,
265
+ paddingLength: Int = 0,
266
+ containerID: Int,
267
+ containerName: String? = null,
268
+ isEventCapture: Boolean = false,
269
+ content: String? = null
270
+ ): ByteArray {
271
+ val w = ProtobufWriter()
272
+ w.writeInt32Field(1, x)
273
+ w.writeInt32Field(2, y)
274
+ w.writeInt32Field(3, width)
275
+ w.writeInt32Field(4, height)
276
+ w.writeInt32Field(5, borderWidth)
277
+ w.writeInt32Field(6, borderColor)
278
+ w.writeInt32Field(7, borderRadius)
279
+ w.writeInt32Field(8, paddingLength)
280
+ w.writeInt32Field(9, containerID)
281
+ containerName?.let { w.writeStringField(10, it) }
282
+ w.writeInt32Field(11, if (isEventCapture) 1 else 0)
283
+ content?.let { w.writeStringField(12, it) }
284
+ return w.toByteArray()
285
+ }
286
+
287
+ fun imageContainerProperty(
288
+ x: Int,
289
+ y: Int,
290
+ width: Int,
291
+ height: Int,
292
+ containerID: Int,
293
+ containerName: String? = null
294
+ ): ByteArray {
295
+ val w = ProtobufWriter()
296
+ w.writeInt32Field(1, x)
297
+ w.writeInt32Field(2, y)
298
+ w.writeInt32Field(3, width)
299
+ w.writeInt32Field(4, height)
300
+ w.writeInt32Field(5, containerID)
301
+ containerName?.let { w.writeStringField(6, it) }
302
+ return w.toByteArray()
303
+ }
304
+
305
+ fun imageRawDataUpdate(
306
+ containerID: Int,
307
+ containerName: String? = null,
308
+ mapSessionId: Int,
309
+ mapTotalSize: Int,
310
+ compressMode: Int = 0,
311
+ mapFragmentIndex: Int,
312
+ mapFragmentPacketSize: Int,
313
+ mapRawData: ByteArray
314
+ ): ByteArray {
315
+ val w = ProtobufWriter()
316
+ w.writeInt32Field(1, containerID)
317
+ containerName?.let { w.writeStringField(2, it) }
318
+ w.writeInt32Field(3, mapSessionId)
319
+ w.writeInt32Field(4, mapTotalSize)
320
+ w.writeInt32Field(5, compressMode)
321
+ w.writeInt32Field(6, mapFragmentIndex)
322
+ w.writeInt32Field(7, mapFragmentPacketSize)
323
+ w.writeBytesField(8, mapRawData)
324
+ return w.toByteArray()
325
+ }
326
+
327
+ fun createStartupPageContainer(
328
+ containerTotalNum: Int,
329
+ textContainers: List<ByteArray> = emptyList(),
330
+ imageContainers: List<ByteArray> = emptyList()
331
+ ): ByteArray {
332
+ val w = ProtobufWriter()
333
+ w.writeInt32Field(1, containerTotalNum)
334
+ for (tc in textContainers) w.writeMessageField(3, tc)
335
+ for (ic in imageContainers) w.writeMessageField(4, ic)
336
+ return w.toByteArray()
337
+ }
338
+
339
+ fun textContainerUpgrade(
340
+ containerID: Int,
341
+ contentOffset: Int = 0,
342
+ contentLength: Int,
343
+ content: String
344
+ ): ByteArray {
345
+ val w = ProtobufWriter()
346
+ w.writeInt32Field(1, containerID)
347
+ w.writeInt32Field(3, contentOffset)
348
+ w.writeInt32Field(4, contentLength)
349
+ w.writeStringField(5, content)
350
+ return w.toByteArray()
351
+ }
352
+
353
+ fun shutdownContainer(exitMode: Int = 0): ByteArray {
354
+ val w = ProtobufWriter()
355
+ w.writeInt32Field(1, exitMode)
356
+ return w.toByteArray()
357
+ }
358
+
359
+ fun heartbeatPacket(cnt: Int = 0): ByteArray {
360
+ val w = ProtobufWriter()
361
+ if (cnt != 0) w.writeInt32Field(1, cnt)
362
+ return w.toByteArray()
363
+ }
364
+
365
+ fun audioCtrCmd(enable: Boolean): ByteArray {
366
+ val w = ProtobufWriter()
367
+ w.writeInt32Field(1, if (enable) 1 else 0)
368
+ return w.toByteArray()
369
+ }
370
+
371
+ fun evenHubMessage(
372
+ cmd: EvenHubCmd,
373
+ subFieldNumber: Int,
374
+ subMessage: ByteArray,
375
+ magicRandom: Int = 0,
376
+ appId: Int? = null
377
+ ): ByteArray {
378
+ val w = ProtobufWriter()
379
+ w.writeInt32Field(1, cmd.value) // Cmd (field 1, enum)
380
+ w.writeInt32Field(2, magicRandom) // MagicRandom (field 2)
381
+ w.writeMessageField(subFieldNumber, subMessage) // the actual command payload
382
+ appId?.let { w.writeInt32Field(5, it) } // Associate page with a menu item appId
383
+ return w.toByteArray()
384
+ }
385
+
386
+ fun createPageMessage(
387
+ textContainers: List<ByteArray> = emptyList(),
388
+ imageContainers: List<ByteArray> = emptyList(),
389
+ magicRandom: Int = 0,
390
+ appId: Int? = null
391
+ ): ByteArray {
392
+ val total = textContainers.size + imageContainers.size
393
+ val createMsg = createStartupPageContainer(total, textContainers, imageContainers)
394
+ return evenHubMessage(
395
+ EvenHubCmd.CREATE_STARTUP_PAGE,
396
+ 3,
397
+ createMsg,
398
+ magicRandom = magicRandom,
399
+ appId = null
400
+ )
401
+ }
402
+
403
+ fun rebuildPageMessage(
404
+ textContainers: List<ByteArray> = emptyList(),
405
+ imageContainers: List<ByteArray> = emptyList(),
406
+ magicRandom: Int = 0,
407
+ appId: Int? = null
408
+ ): ByteArray {
409
+ val total = textContainers.size + imageContainers.size
410
+ val rebuildMsg = createStartupPageContainer(total, textContainers, imageContainers)
411
+ return evenHubMessage(
412
+ EvenHubCmd.REBUILD_PAGE,
413
+ 7,
414
+ rebuildMsg,
415
+ magicRandom = magicRandom,
416
+ appId = appId
417
+ )
418
+ }
419
+
420
+ fun updateImageRawDataMessage(
421
+ containerID: Int,
422
+ containerName: String? = null,
423
+ mapSessionId: Int,
424
+ mapTotalSize: Int,
425
+ compressMode: Int = 0,
426
+ mapFragmentIndex: Int,
427
+ mapFragmentPacketSize: Int,
428
+ mapRawData: ByteArray
429
+ ): ByteArray {
430
+ val updateMsg =
431
+ imageRawDataUpdate(
432
+ containerID,
433
+ containerName,
434
+ mapSessionId,
435
+ mapTotalSize,
436
+ compressMode,
437
+ mapFragmentIndex,
438
+ mapFragmentPacketSize,
439
+ mapRawData
440
+ )
441
+ return evenHubMessage(EvenHubCmd.UPDATE_IMAGE_RAW_DATA, 5, updateMsg)
442
+ }
443
+
444
+ fun updateTextMessage(
445
+ containerID: Int,
446
+ contentOffset: Int = 0,
447
+ contentLength: Int,
448
+ content: String
449
+ ): ByteArray {
450
+ val upgradeMsg = textContainerUpgrade(containerID, contentOffset, contentLength, content)
451
+ return evenHubMessage(EvenHubCmd.UPDATE_TEXT_DATA, 9, upgradeMsg)
452
+ }
453
+
454
+ fun shutdownMessage(exitMode: Int = 0): ByteArray {
455
+ val msg = shutdownContainer(exitMode)
456
+ return evenHubMessage(EvenHubCmd.SHUTDOWN_PAGE, 11, msg)
457
+ }
458
+
459
+ fun heartbeatMessage(magicRandom: Int = 0): ByteArray {
460
+ val msg = heartbeatPacket()
461
+ return evenHubMessage(EvenHubCmd.HEARTBEAT, 14, msg, magicRandom = magicRandom)
462
+ }
463
+
464
+ fun audioControlMessage(enable: Boolean, magicRandom: Int = 0): ByteArray {
465
+ val msg = audioCtrCmd(enable)
466
+ return evenHubMessage(EvenHubCmd.AUDIO_CONTROL, 18, msg, magicRandom = magicRandom)
467
+ }
468
+ }
469
+
470
+ // ---------- DevSettings Auth Protobuf Builders ----------
471
+
472
+ private object DevSettingsProto {
473
+ fun authCmd(magicRandom: Int): ByteArray {
474
+ val w = ProtobufWriter()
475
+ w.writeInt32Field(1, DevCfgCommandId.AUTHENTICATION.value)
476
+ w.writeInt32Field(2, magicRandom)
477
+
478
+ // AuthMgr sub-message
479
+ val authW = ProtobufWriter()
480
+ authW.writeBoolField(1, true) // secAuth
481
+ authW.writeInt32Field(2, 4) // phoneType = PHONE_ANDROID (4)
482
+
483
+ w.writeMessageField(3, authW.toByteArray())
484
+ return w.toByteArray()
485
+ }
486
+
487
+ fun pipeRoleChange(magicRandom: Int): ByteArray {
488
+ val w = ProtobufWriter()
489
+ w.writeInt32Field(1, DevCfgCommandId.PIPE_ROLE_CHANGE.value)
490
+ w.writeInt32Field(2, magicRandom)
491
+
492
+ // PipeRoleChange: field 1 = asCmdRole (GlassesLR.RIGHT=1)
493
+ val roleW = ProtobufWriter()
494
+ roleW.writeInt32Field(1, 1) // RIGHT
495
+ w.writeMessageField(4, roleW.toByteArray())
496
+ return w.toByteArray()
497
+ }
498
+
499
+ fun timeSync(magicRandom: Int): ByteArray {
500
+ val w = ProtobufWriter()
501
+ w.writeInt32Field(1, DevCfgCommandId.TIME_SYNC.value)
502
+ w.writeInt32Field(2, magicRandom)
503
+
504
+ val tsW = ProtobufWriter()
505
+ val timestamp = (System.currentTimeMillis() / 1000).toInt()
506
+ tsW.writeInt32Field(1, timestamp)
507
+ val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 3600000
508
+ tsW.writeInt32Field(2, tz)
509
+ w.writeMessageField(128, tsW.toByteArray())
510
+ return w.toByteArray()
511
+ }
512
+
513
+ fun baseHeartbeat(magicRandom: Int): ByteArray {
514
+ val w = ProtobufWriter()
515
+ w.writeInt32Field(1, DevCfgCommandId.BASE_CONN_HEART_BEAT.value)
516
+ w.writeInt32Field(2, magicRandom)
517
+
518
+ // BaseConnHeartBeat: empty message
519
+ val hbW = ProtobufWriter()
520
+ w.writeMessageField(13, hbW.toByteArray())
521
+ return w.toByteArray()
522
+ }
523
+
524
+ fun ringConnectInfo(
525
+ magicRandom: Int,
526
+ connect: Boolean,
527
+ ringMac: ByteArray,
528
+ ringName: String = ""
529
+ ): ByteArray {
530
+ val w = ProtobufWriter()
531
+ w.writeInt32Field(
532
+ 1,
533
+ DevCfgCommandId.RING_CONNECT_INFO.value
534
+ ) // commandId = RING_CONNECT_INFO (6)
535
+ w.writeInt32Field(2, magicRandom)
536
+
537
+ // RingInfo sub-message (field 5 in DevCfgDataPackage)
538
+ val ringW = ProtobufWriter()
539
+ ringW.writeBoolField(1, connect) // connectRing
540
+ ringW.writeBytesField(2, ringMac) // ringMac (6 bytes)
541
+ if (ringName.isNotEmpty()) {
542
+ ringW.writeBytesField(3, ringName.toByteArray(Charsets.UTF_8)) // ringName
543
+ }
544
+
545
+ w.writeMessageField(5, ringW.toByteArray()) // ringInfo (field 5)
546
+ return w.toByteArray()
547
+ }
548
+ }
549
+
550
+ // ---------- G2 Settings Protobuf Builders ----------
551
+
552
+ private object G2SettingProto {
553
+ fun setBrightness(magicRandom: Int, level: Int, autoAdjust: Boolean): ByteArray {
554
+ val brightnessW = ProtobufWriter()
555
+ brightnessW.writeInt32Field(1, if (autoAdjust) 1 else 0)
556
+ brightnessW.writeInt32Field(2, level)
557
+
558
+ val infoW = ProtobufWriter()
559
+ infoW.writeMessageField(1, brightnessW.toByteArray())
560
+
561
+ val w = ProtobufWriter()
562
+ w.writeInt32Field(1, G2SettingCommandId.DEVICE_RECEIVE_INFO.value)
563
+ w.writeInt32Field(2, magicRandom)
564
+ w.writeMessageField(3, infoW.toByteArray())
565
+ return w.toByteArray()
566
+ }
567
+
568
+ fun requestInfo(magicRandom: Int): ByteArray {
569
+ val reqW = ProtobufWriter()
570
+ reqW.writeInt32Field(1, 1) // settingInfoType = APP_REQUIRE_BASIC_SETTING
571
+
572
+ val w = ProtobufWriter()
573
+ w.writeInt32Field(1, G2SettingCommandId.DEVICE_RECEIVE_REQUEST.value)
574
+ w.writeInt32Field(2, magicRandom)
575
+ w.writeMessageField(4, reqW.toByteArray())
576
+ return w.toByteArray()
577
+ }
578
+
579
+ fun setHeadUpSwitch(magicRandom: Int, enabled: Boolean): ByteArray {
580
+ // DeviceReceive_Head_UP_Setting
581
+ val headUpW = ProtobufWriter()
582
+ headUpW.writeInt32Field(1, if (enabled) 1 else 0) // headUpSwitch
583
+
584
+ // DeviceReceiveInfoFromAPP
585
+ val infoW = ProtobufWriter()
586
+ infoW.writeMessageField(4, headUpW.toByteArray()) // deviceReceiveHeadUpSetting (field 4)
587
+
588
+ // G2SettingPackage
589
+ val w = ProtobufWriter()
590
+ w.writeInt32Field(1, G2SettingCommandId.DEVICE_RECEIVE_INFO.value)
591
+ w.writeInt32Field(2, magicRandom)
592
+ w.writeMessageField(3, infoW.toByteArray()) // deviceReceiveInfoFromApp (field 3)
593
+ return w.toByteArray()
594
+ }
595
+
596
+ fun setHeadUpAngle(magicRandom: Int, angle: Int): ByteArray {
597
+ // DeviceReceive_Head_UP_Setting
598
+ val headUpW = ProtobufWriter()
599
+ headUpW.writeInt32Field(2, angle) // headUpAngle (field 2)
600
+
601
+ // DeviceReceiveInfoFromAPP
602
+ val infoW = ProtobufWriter()
603
+ infoW.writeMessageField(4, headUpW.toByteArray()) // deviceReceiveHeadUpSetting (field 4)
604
+
605
+ // G2SettingPackage
606
+ val w = ProtobufWriter()
607
+ w.writeInt32Field(1, G2SettingCommandId.DEVICE_RECEIVE_INFO.value)
608
+ w.writeInt32Field(2, magicRandom)
609
+ w.writeMessageField(3, infoW.toByteArray())
610
+ return w.toByteArray()
611
+ }
612
+
613
+ fun setScreenHeight(magicRandom: Int, level: Int): ByteArray {
614
+ // DeviceReceive_Y_Coordinate
615
+ val yW = ProtobufWriter()
616
+ yW.writeInt32Field(1, level) // yCoordinateLevel
617
+
618
+ // DeviceReceiveInfoFromAPP
619
+ val infoW = ProtobufWriter()
620
+ infoW.writeMessageField(2, yW.toByteArray()) // deviceReceiveYCoordinate (field 2)
621
+
622
+ // G2SettingPackage
623
+ val w = ProtobufWriter()
624
+ w.writeInt32Field(1, G2SettingCommandId.DEVICE_RECEIVE_INFO.value)
625
+ w.writeInt32Field(2, magicRandom)
626
+ w.writeMessageField(3, infoW.toByteArray())
627
+ return w.toByteArray()
628
+ }
629
+
630
+ fun setScreenDepth(magicRandom: Int, level: Int): ByteArray {
631
+ // DeviceReceive_X_Coordinate
632
+ val xW = ProtobufWriter()
633
+ xW.writeInt32Field(1, level) // xCoordinateLevel
634
+
635
+ // DeviceReceiveInfoFromAPP
636
+ val infoW = ProtobufWriter()
637
+ infoW.writeMessageField(3, xW.toByteArray()) // deviceReceiveXCoordinate (field 3)
638
+
639
+ // G2SettingPackage
640
+ val w = ProtobufWriter()
641
+ w.writeInt32Field(1, G2SettingCommandId.DEVICE_RECEIVE_INFO.value)
642
+ w.writeInt32Field(2, magicRandom)
643
+ w.writeMessageField(3, infoW.toByteArray())
644
+ return w.toByteArray()
645
+ }
646
+ }
647
+
648
+ // ---------- Onboarding Protobuf Builders ----------
649
+
650
+ private object OnboardingProto {
651
+ fun skipOnboarding(magicRandom: Int): ByteArray {
652
+ val configW = ProtobufWriter()
653
+ configW.writeInt32Field(1, 4) // processId = FINISH
654
+
655
+ val w = ProtobufWriter()
656
+ w.writeInt32Field(1, 1) // commandId = CONFIG
657
+ w.writeInt32Field(2, magicRandom)
658
+ w.writeMessageField(3, configW.toByteArray())
659
+ return w.toByteArray()
660
+ }
661
+ }
662
+
663
+ // ---------- EvenAI Protobuf Builders (even_ai.proto, service ID 7) ----------
664
+
665
+ private object EvenAIProto {
666
+ fun setHeyEven(magicRandom: Int, enabled: Boolean): ByteArray {
667
+ // EvenAIConfig
668
+ val configW = ProtobufWriter()
669
+ configW.writeInt32Field(1, if (enabled) 1 else 0) // voiceSwitch
670
+ configW.writeInt32Field(2, 80) // streamSpeed (always sent)
671
+
672
+ // EvenAIDataPackage
673
+ val w = ProtobufWriter()
674
+ w.writeInt32Field(1, 10) // commandId = CONFIG
675
+ w.writeInt32Field(2, magicRandom)
676
+ w.writeMessageField(13, configW.toByteArray()) // config (field 13)
677
+ return w.toByteArray()
678
+ }
679
+ }
680
+
681
+ // ---------- Menu Protobuf Builders (menu.proto, service ID 3) ----------
682
+
683
+ private object MenuProto {
684
+ data class MenuItem(val packageName: String, val name: String, val running: Boolean)
685
+
686
+ const val MIN_MENU_SIZE = 5
687
+ const val MAX_MENU_SIZE = 10
688
+ const val MAX_NAME_LENGTH = 15 // 17 char limit minus 2 for running indicator prefix
689
+ val PLACEHOLDER_APP_IDS = listOf(10535, 10536, 10537, 10538, 10539)
690
+
691
+ /** Deterministic hash of packageName -> numeric appId in range 10029-10534 */
692
+ fun packageNameToAppId(packageName: String): Int {
693
+ var hash = 0
694
+ for (char in packageName) {
695
+ hash = (hash shl 5) - hash + char.code
696
+ }
697
+ // 506 values: 10029-10534 (reserve 10535-10539 for placeholders)
698
+ return 10029 + (kotlin.math.abs(hash) % 506)
699
+ }
700
+
701
+ /**
702
+ * meun_main_msg_ctx with APP_SEND_MENU_INFO command Handles: name truncation (15 chars),
703
+ * running prefix, padding to 5, cap at 10 Always prepends the built-in Notification item as the
704
+ * first entry. Returns (protobuf data, appId->packageName mapping for reverse lookup)
705
+ */
706
+ fun sendMenuInfo(magicRandom: Int, items: List<MenuItem>): Pair<ByteArray, Map<Int, String>> {
707
+ val appIdMap = mutableMapOf<Int, String>()
708
+
709
+ data class WireItem(val displayName: String?, val appId: Int, val isBuiltIn: Boolean)
710
+ val wireItems = mutableListOf<WireItem>()
711
+
712
+ // Always first: built-in Notification (SID=4)
713
+ wireItems.add(WireItem(null, 4, true))
714
+
715
+ // Third-party items — leave room for the built-in
716
+ for (item in items.take(MAX_MENU_SIZE - 1)) {
717
+ val appId = packageNameToAppId(item.packageName)
718
+ appIdMap[appId] = item.packageName
719
+
720
+ val truncated =
721
+ if (item.name.length > MAX_NAME_LENGTH) item.name.take(MAX_NAME_LENGTH)
722
+ else item.name
723
+ val prefix = if (item.running) "● " else " "
724
+ wireItems.add(WireItem(prefix + truncated, appId, false))
725
+ }
726
+
727
+ // Pad to MIN_MENU_SIZE with placeholder third-party items
728
+ while (wireItems.size < MIN_MENU_SIZE) {
729
+ val idx = wireItems.size - 1 // -1 because built-in occupies slot 0
730
+ wireItems.add(WireItem(" ---", PLACEHOLDER_APP_IDS[idx], false))
731
+ }
732
+
733
+ // MenuInfoSend
734
+ val menuW = ProtobufWriter()
735
+ menuW.writeInt32Field(1, wireItems.size) // itemTotalNum
736
+
737
+ for (item in wireItems) {
738
+ val itemW = ProtobufWriter()
739
+ if (item.isBuiltIn) {
740
+ itemW.writeInt32Field(1, 0) // itemType = 0 (built-in)
741
+ itemW.writeInt32Field(4, item.appId) // itemAppId = SID
742
+ } else {
743
+ itemW.writeInt32Field(1, 1) // itemType = 1 (third-party)
744
+ itemW.writeInt32Field(2, 1) // iconNum = 1
745
+ itemW.writeStringField(3, item.displayName ?: "") // itemName
746
+ itemW.writeInt32Field(4, item.appId) // itemAppId
747
+ }
748
+ menuW.writeMessageField(2, itemW.toByteArray()) // repeated item (field 2)
749
+ }
750
+
751
+ // meun_main_msg_ctx
752
+ val w = ProtobufWriter()
753
+ w.writeInt32Field(1, 0) // Cmd = APP_SEND_MENU_INFO (0)
754
+ w.writeInt32Field(2, magicRandom) // MagicRandom
755
+ w.writeMessageField(3, menuW.toByteArray()) // sendData (field 3)
756
+ return Pair(w.toByteArray(), appIdMap)
757
+ }
758
+ }
759
+
760
+ // ---------- EvenBLE Transport Layer ----------
761
+
762
+ private object EvenBLETransport {
763
+ fun buildPackets(
764
+ syncId: Byte,
765
+ serviceId: Byte,
766
+ payload: ByteArray,
767
+ reserveFlag: Boolean = false
768
+ ): List<ByteArray> {
769
+ val maxPayload = G2BLE.MAX_PACKET_PAYLOAD
770
+
771
+ // Split payload into chunks
772
+ val chunks = mutableListOf<ByteArray>()
773
+ var offset = 0
774
+ while (offset < payload.size) {
775
+ val end = minOf(offset + maxPayload, payload.size)
776
+ chunks.add(payload.copyOfRange(offset, end))
777
+ offset = end
778
+ }
779
+ if (chunks.isEmpty()) {
780
+ chunks.add(ByteArray(0))
781
+ }
782
+
783
+ // If last chunk is exactly max size, need extra packet for CRC
784
+ if (chunks.last().size == maxPayload) {
785
+ chunks.add(ByteArray(0))
786
+ }
787
+
788
+ val totalPackets = chunks.size.toByte()
789
+ val crc = calcCRC16(payload)
790
+
791
+ val packets = mutableListOf<ByteArray>()
792
+ for ((i, chunk) in chunks.withIndex()) {
793
+ val serialNum = (i + 1).toByte()
794
+ val isLast = serialNum == totalPackets
795
+
796
+ // status byte: bit5=reserveFlag
797
+ val status: Byte = if (reserveFlag) 0x20 else 0x00
798
+
799
+ // payload length includes CRC if last packet
800
+ val payloadLen = (chunk.size + if (isLast) 2 else 0).toByte()
801
+
802
+ val packet = ByteArrayOutputStream()
803
+ packet.write(G2BLE.HEADER_BYTE.toInt() and 0xFF)
804
+ packet.write(
805
+ ((G2BLE.DEST_GLASSES.toInt() shl 4) or G2BLE.SOURCE_PHONE.toInt()) and 0xFF
806
+ )
807
+ packet.write(syncId.toInt() and 0xFF)
808
+ packet.write(payloadLen.toInt() and 0xFF)
809
+ packet.write(totalPackets.toInt() and 0xFF)
810
+ packet.write(serialNum.toInt() and 0xFF)
811
+ packet.write(serviceId.toInt() and 0xFF)
812
+ packet.write(status.toInt() and 0xFF)
813
+
814
+ packet.write(chunk)
815
+
816
+ if (isLast) {
817
+ packet.write(crc and 0xFF)
818
+ packet.write((crc shr 8) and 0xFF)
819
+ }
820
+
821
+ packets.add(packet.toByteArray())
822
+ }
823
+
824
+ return packets
825
+ }
826
+ }
827
+
828
+ // ---------- G2 Send Manager ----------
829
+
830
+ private class G2SendManager {
831
+ private var syncId: Byte = 0
832
+ private var magicRandom: Byte = 0
833
+
834
+ fun nextSyncId(): Byte {
835
+ val id = syncId
836
+ syncId = (syncId + 1).toByte()
837
+ return id
838
+ }
839
+
840
+ fun nextMagicRandom(): Int {
841
+ val v = magicRandom
842
+ magicRandom = (magicRandom + 1).toByte()
843
+ return v.toInt() and 0xFF
844
+ }
845
+
846
+ fun buildPackets(
847
+ serviceId: Byte,
848
+ payload: ByteArray,
849
+ reserveFlag: Boolean = false
850
+ ): List<ByteArray> {
851
+ val sid = nextSyncId()
852
+ return EvenBLETransport.buildPackets(sid, serviceId, payload, reserveFlag)
853
+ }
854
+ }
855
+
856
+ // ---------- G2 Receive Manager ----------
857
+
858
+ private class G2ReceiveManager {
859
+ private val partials = mutableMapOf<String, Pair<ByteArrayOutputStream, Byte>>()
860
+
861
+ fun handlePacket(rawData: ByteArray, sourceKey: String = ""): Pair<Byte, ByteArray>? {
862
+ if (rawData.size < 8) return null
863
+ if (rawData[0] != G2BLE.HEADER_BYTE) return null
864
+
865
+ val payloadLen = rawData[3].toInt() and 0xFF
866
+ val expectedLen = payloadLen + 8
867
+ if (rawData.size < expectedLen) return null
868
+
869
+ val totalPackets = rawData[4]
870
+ val serialNum = rawData[5]
871
+ val serviceId = rawData[6]
872
+ val status = rawData[7].toInt() and 0xFF
873
+ val resultCode = (status shr 1) and 0x0F
874
+
875
+ if (resultCode != 0) return null
876
+
877
+ val isLast = serialNum == totalPackets
878
+ val hasCrc = isLast
879
+ val payloadEnd = 8 + payloadLen - if (hasCrc) 2 else 0
880
+ val payload = rawData.copyOfRange(8, payloadEnd)
881
+
882
+ val syncId = rawData[2]
883
+ // Include sourceKey so concurrent multi-packet responses from the L and R lenses with
884
+ // the same syncId don't cross-merge into one broken payload.
885
+ val key = "$sourceKey-${serviceId.toInt() and 0xFF}-${syncId.toInt() and 0xFF}"
886
+
887
+ if ((serialNum.toInt() and 0xFF) > 1) {
888
+ val existing = partials[key] ?: return null
889
+ existing.first.write(payload)
890
+ partials[key] = Pair(existing.first, serialNum)
891
+ } else if ((totalPackets.toInt() and 0xFF) > 1) {
892
+ val baos = ByteArrayOutputStream()
893
+ baos.write(payload)
894
+ partials[key] = Pair(baos, serialNum)
895
+ }
896
+
897
+ if (!isLast) return null
898
+
899
+ val fullPayload: ByteArray
900
+ val existing = partials[key]
901
+ if (existing != null) {
902
+ fullPayload = existing.first.toByteArray()
903
+ partials.remove(key)
904
+ } else {
905
+ fullPayload = payload
906
+ }
907
+
908
+ return Pair(serviceId, fullPayload)
909
+ }
910
+ }
911
+
912
+ // ---------- G2 Reconnection Manager ----------
913
+
914
+ private class G2ReconnectionManager(
915
+ private val intervalMs: Long = 30_000L,
916
+ private val maxAttempts: Int = -1 // -1 for unlimited
917
+ ) {
918
+ private val handler = Handler(Looper.getMainLooper())
919
+ private var runnable: Runnable? = null
920
+ private var attempts = 0
921
+ val isRunning: Boolean
922
+ get() = runnable != null
923
+
924
+ fun start(onAttempt: () -> Boolean) {
925
+ stop()
926
+ attempts = 0
927
+
928
+ val r =
929
+ object : Runnable {
930
+ override fun run() {
931
+ if (maxAttempts > 0 && attempts >= maxAttempts) {
932
+ Bridge.log("G2: Max reconnection attempts ($maxAttempts) reached")
933
+ stop()
934
+ return
935
+ }
936
+
937
+ attempts++
938
+ Bridge.log("G2: Reconnection attempt $attempts")
939
+
940
+ val shouldStop = onAttempt()
941
+ if (shouldStop) {
942
+ Bridge.log("G2: Reconnection successful, stopping")
943
+ stop()
944
+ return
945
+ }
946
+
947
+ handler.postDelayed(this, intervalMs)
948
+ }
949
+ }
950
+ runnable = r
951
+ handler.postDelayed(r, intervalMs)
952
+ }
953
+
954
+ fun stop() {
955
+ runnable?.let { handler.removeCallbacks(it) }
956
+ runnable = null
957
+ attempts = 0
958
+ }
959
+ }
960
+
961
+ // ---------- G2 Class ----------
962
+
963
+ class G2 : SGCManager() {
964
+
965
+ companion object {
966
+ private const val PREFS_NAME = "G2Prefs"
967
+ private const val KEY_LEFT_ADDRESS = "g2_leftGlassAddress"
968
+ private const val KEY_RIGHT_ADDRESS = "g2_rightGlassAddress"
969
+ }
970
+
971
+ init {
972
+ type = DeviceTypes.G2
973
+ hasMic = true
974
+ }
975
+
976
+ // BLE
977
+ private val context: Context
978
+ get() = Bridge.getContext()
979
+ private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
980
+ private var leftGatt: BluetoothGatt? = null
981
+ private var rightGatt: BluetoothGatt? = null
982
+ private var leftWriteChar: BluetoothGattCharacteristic? = null
983
+ private var rightWriteChar: BluetoothGattCharacteristic? = null
984
+ private var leftNotifyChar: BluetoothGattCharacteristic? = null
985
+ private var rightNotifyChar: BluetoothGattCharacteristic? = null
986
+ private var leftAudioChar: BluetoothGattCharacteristic? = null
987
+ private var rightAudioChar: BluetoothGattCharacteristic? = null
988
+ private var leftInitialized: Boolean = false
989
+ private var rightInitialized: Boolean = false
990
+ private var isDisconnecting = false
991
+ private var pairingTimeoutRunnable: Runnable? = null
992
+
993
+ // Device search
994
+ private var DEVICE_SEARCH_ID = "NOT_SET"
995
+ // Map device names to serial numbers (populated from manufacturer data during scan)
996
+ private val deviceNameToSerialNumber = mutableMapOf<String, String>()
997
+
998
+ // Saved addresses for reconnection
999
+ private var leftGlassAddress: String?
1000
+ get() =
1001
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1002
+ .getString(KEY_LEFT_ADDRESS, null)
1003
+ set(value) {
1004
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1005
+ .edit()
1006
+ .apply {
1007
+ if (value != null) putString(KEY_LEFT_ADDRESS, value)
1008
+ else remove(KEY_LEFT_ADDRESS)
1009
+ }
1010
+ .apply()
1011
+ }
1012
+
1013
+ private var rightGlassAddress: String?
1014
+ get() =
1015
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1016
+ .getString(KEY_RIGHT_ADDRESS, null)
1017
+ set(value) {
1018
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
1019
+ .edit()
1020
+ .apply {
1021
+ if (value != null) putString(KEY_RIGHT_ADDRESS, value)
1022
+ else remove(KEY_RIGHT_ADDRESS)
1023
+ }
1024
+ .apply()
1025
+ }
1026
+
1027
+ // Reconnection
1028
+ private val reconnectionManager = G2ReconnectionManager()
1029
+
1030
+ // Protocol state
1031
+ private val sendManager = G2SendManager()
1032
+ private val receiveManager = G2ReceiveManager()
1033
+ private val mainHandler = Handler(Looper.getMainLooper())
1034
+ private var heartbeatRunnable: Runnable? = null
1035
+ private var devSettingsHeartbeatRunnable: Runnable? = null
1036
+ private var evenHubQueueRunnable: Runnable? = null
1037
+ private var pendingTextMsg: ByteArray? = null
1038
+ private var lastEvenHubMsg: ByteArray? = null
1039
+ private var lastEvenHubResendsRemaining: Int = 0
1040
+ private val EVEN_HUB_RESEND_COUNT: Int = 1
1041
+ private val EVEN_HUB_QUEUE_TICK_MS = 100L
1042
+ private var startupPageCreated: Boolean = false
1043
+ private var pageCreated: Boolean = false
1044
+ private var pageHasTextContainer: Boolean = false
1045
+ private var currentTextContent: String = ""
1046
+ private var textContainerID: Int = 1
1047
+ private var imageSessionCounter: Int = 0
1048
+ private var heartbeatCounter: Int = 0
1049
+ private var authStarted: Boolean = false
1050
+ private var leftAuthenticated: Boolean = false
1051
+ private var rightAuthenticated: Boolean = false
1052
+ private var currentBitmapBase64: String = ""
1053
+
1054
+ // Dashboard menu state
1055
+ private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
1056
+ private var dashboardMenuItems: MutableList<MenuProto.MenuItem> = mutableListOf()
1057
+ private var activeMenuAppId: Int? = null
1058
+ private var lastClickTimestamp: Long? = null
1059
+ private var lastMenuSelectTimestamp: Long? = null
1060
+
1061
+ // Battery state
1062
+ private var _batteryLevel: Int = -1
1063
+ private var batteryLevel_: Int
1064
+ get() = _batteryLevel
1065
+ set(value) {
1066
+ val old = _batteryLevel
1067
+ _batteryLevel = value
1068
+ if (value != old && value >= 0) {
1069
+ DeviceStore.apply("glasses", "batteryLevel", value)
1070
+ Bridge.sendBatteryStatus(value, isCharging)
1071
+ }
1072
+ }
1073
+ private var isCharging: Boolean = false
1074
+
1075
+ // Scanning
1076
+ private var scanCallback: ScanCallback? = null
1077
+
1078
+ // GATT operation queue for descriptor writes
1079
+ private val gattOpQueue = mutableListOf<() -> Unit>()
1080
+ private var gattOpInProgress = false
1081
+
1082
+ // ---------- BLE Sending ----------
1083
+
1084
+ // Min gap between BLE packets when bursting many in a row. Android serializes one in-flight
1085
+ // GATT op at a time even for WRITE_TYPE_NO_RESPONSE, so back-to-back writeCharacteristic() in
1086
+ // a tight loop drops packets silently. iOS gets this for free via CoreBluetooth; we don't.
1087
+ // Matches the 8 ms G1.java uses for its bitmap chunk loop (ANDROID_CHUNK_DELAY_MS).
1088
+ private val BLE_PACKET_GAP_MS = 8L
1089
+
1090
+ @Suppress("deprecation")
1091
+ private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) {
1092
+ if (right) {
1093
+ rightWriteChar?.let { char ->
1094
+ rightGatt?.let { gatt ->
1095
+ char.value = packet
1096
+ char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1097
+ gatt.writeCharacteristic(char)
1098
+ }
1099
+ }
1100
+ }
1101
+ if (left) {
1102
+ leftWriteChar?.let { char ->
1103
+ leftGatt?.let { gatt ->
1104
+ char.value = packet
1105
+ char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1106
+ gatt.writeCharacteristic(char)
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ private fun sendToGlasses(
1113
+ packets: List<ByteArray>,
1114
+ left: Boolean = false,
1115
+ right: Boolean = true
1116
+ ) {
1117
+ if (packets.isEmpty()) return
1118
+ // Single-packet sends (the common case for text/settings) go straight through.
1119
+ if (packets.size == 1) {
1120
+ writeOnePacket(packets[0], left, right)
1121
+ return
1122
+ }
1123
+ // Multi-packet bursts (bitmaps, large protobufs): write the first packet immediately,
1124
+ // then schedule the rest with BLE_PACKET_GAP_MS spacing so the Android BLE stack can
1125
+ // actually drain each write before the next one is queued.
1126
+ writeOnePacket(packets[0], left, right)
1127
+ for (i in 1 until packets.size) {
1128
+ val packet = packets[i]
1129
+ mainHandler.postDelayed({ writeOnePacket(packet, left, right) }, BLE_PACKET_GAP_MS * i)
1130
+ }
1131
+ }
1132
+
1133
+ private fun sendEvenHubCommand(payload: ByteArray) {
1134
+ val packets =
1135
+ sendManager.buildPackets(
1136
+ serviceId = ServiceID.EVEN_HUB.value,
1137
+ payload = payload,
1138
+ reserveFlag = true
1139
+ )
1140
+ sendToGlasses(packets)
1141
+ }
1142
+
1143
+ private fun sendDevSettingsCommand(
1144
+ payload: ByteArray,
1145
+ left: Boolean = false,
1146
+ right: Boolean = true
1147
+ ) {
1148
+ val packets =
1149
+ sendManager.buildPackets(
1150
+ serviceId = ServiceID.DEVICE_SETTINGS.value,
1151
+ payload = payload
1152
+ )
1153
+ sendToGlasses(packets, left = left, right = right)
1154
+ }
1155
+
1156
+ private fun sendG2SettingCommand(payload: ByteArray) {
1157
+ val packets =
1158
+ sendManager.buildPackets(
1159
+ serviceId = ServiceID.G2_SETTING.value,
1160
+ payload = payload,
1161
+ reserveFlag = true
1162
+ )
1163
+ sendToGlasses(packets)
1164
+ }
1165
+
1166
+ private fun sendOnboardingCommand(payload: ByteArray) {
1167
+ val packets =
1168
+ sendManager.buildPackets(
1169
+ serviceId = ServiceID.ONBOARDING.value,
1170
+ payload = payload,
1171
+ reserveFlag = true
1172
+ )
1173
+ sendToGlasses(packets)
1174
+ }
1175
+
1176
+ private fun sendEvenAICommand(payload: ByteArray) {
1177
+ val packets =
1178
+ sendManager.buildPackets(
1179
+ serviceId = ServiceID.EVEN_AI.value,
1180
+ payload = payload,
1181
+ reserveFlag = true
1182
+ )
1183
+ sendToGlasses(packets)
1184
+ }
1185
+
1186
+ private fun sendMenuCommand(payload: ByteArray) {
1187
+ val packets =
1188
+ sendManager.buildPackets(
1189
+ serviceId = ServiceID.MENU.value,
1190
+ payload = payload,
1191
+ reserveFlag = true
1192
+ )
1193
+ sendToGlasses(packets)
1194
+ }
1195
+
1196
+ private fun sendGestureCtrlCommand(payload: ByteArray) {
1197
+ val packets =
1198
+ sendManager.buildPackets(
1199
+ serviceId = ServiceID.GESTURE_CTRL.value,
1200
+ payload = payload,
1201
+ reserveFlag = true
1202
+ )
1203
+ sendToGlasses(packets)
1204
+ }
1205
+
1206
+ private fun sendEvenHubCtrlCommand(payload: ByteArray) {
1207
+ val packets =
1208
+ sendManager.buildPackets(
1209
+ serviceId = ServiceID.EVEN_HUB_CTRL.value,
1210
+ payload = payload,
1211
+ reserveFlag = true
1212
+ )
1213
+ sendToGlasses(packets)
1214
+ }
1215
+
1216
+ private fun sendDashboardCommand(payload: ByteArray) {
1217
+ val packets =
1218
+ sendManager.buildPackets(
1219
+ serviceId = ServiceID.DASHBOARD.value,
1220
+ payload = payload,
1221
+ reserveFlag = true
1222
+ )
1223
+ sendToGlasses(packets)
1224
+ }
1225
+
1226
+ // ---------- Authentication Sequence ----------
1227
+
1228
+ private fun runAuthSequence() {
1229
+ Bridge.log("G2: Running auth sequence")
1230
+
1231
+ // Auth to left side
1232
+ if (leftGatt != null && leftWriteChar != null) {
1233
+ val authL = DevSettingsProto.authCmd(sendManager.nextMagicRandom())
1234
+ sendDevSettingsCommand(authL, left = true, right = false)
1235
+ }
1236
+
1237
+ // Small delay then auth right + pipe role change + time sync
1238
+ mainHandler.postDelayed(
1239
+ {
1240
+ val authR = DevSettingsProto.authCmd(sendManager.nextMagicRandom())
1241
+ sendDevSettingsCommand(authR, left = false, right = true)
1242
+
1243
+ mainHandler.postDelayed(
1244
+ {
1245
+ val roleChange =
1246
+ DevSettingsProto.pipeRoleChange(
1247
+ sendManager.nextMagicRandom()
1248
+ )
1249
+ sendDevSettingsCommand(roleChange, left = false, right = true)
1250
+
1251
+ mainHandler.postDelayed(
1252
+ {
1253
+ val timeSync =
1254
+ DevSettingsProto.timeSync(
1255
+ sendManager.nextMagicRandom()
1256
+ )
1257
+ sendDevSettingsCommand(timeSync)
1258
+
1259
+ // Skip onboarding on connect
1260
+ mainHandler.postDelayed(
1261
+ {
1262
+ val onboarding =
1263
+ OnboardingProto.skipOnboarding(
1264
+ sendManager
1265
+ .nextMagicRandom()
1266
+ )
1267
+ sendOnboardingCommand(onboarding)
1268
+ Bridge.log(
1269
+ "G2: Sent onboarding skip (FINISH)"
1270
+ )
1271
+
1272
+ // Disable "Hey Even" wakeword on connect
1273
+ val heyEvenOff =
1274
+ EvenAIProto.setHeyEven(
1275
+ sendManager
1276
+ .nextMagicRandom(),
1277
+ false
1278
+ )
1279
+ sendEvenAICommand(heyEvenOff)
1280
+ Bridge.log("G2: Disabled Hey Even wakeword")
1281
+
1282
+ // Replicate Even app's full init sequence
1283
+ // for menu selection support:
1284
+
1285
+ // 0. Universe settings (g2_setting cmd=1
1286
+ // field3 with field9=universe settings)
1287
+ val univW = ProtobufWriter()
1288
+ univW.writeInt32Field(
1289
+ 1,
1290
+ 1
1291
+ ) // DeviceReceiveInfo
1292
+ univW.writeInt32Field(
1293
+ 2,
1294
+ sendManager.nextMagicRandom()
1295
+ )
1296
+ univW.writeMessageField(
1297
+ 3,
1298
+ byteArrayOf(
1299
+ 0x4A,
1300
+ 0x0A, // field 9, length 10
1301
+ 0x08,
1302
+ 0x00, // unitFormat=0
1303
+ 0x10,
1304
+ 0x00, // distanceUnit=0
1305
+ 0x18,
1306
+ 0x01, // timeFormat=1
1307
+ 0x20,
1308
+ 0x00, // dateFormat=0
1309
+ 0x28,
1310
+ 0x01 // temperatureUnit=1
1311
+ )
1312
+ )
1313
+ sendG2SettingCommand(univW.toByteArray())
1314
+
1315
+ // 1. gesture_ctrl init (field1=0,
1316
+ // field2=magicRandom)
1317
+ val gestureInitW = ProtobufWriter()
1318
+ gestureInitW.writeInt32Field(1, 0)
1319
+ gestureInitW.writeInt32Field(
1320
+ 2,
1321
+ sendManager.nextMagicRandom()
1322
+ )
1323
+ sendGestureCtrlCommand(
1324
+ gestureInitW.toByteArray()
1325
+ )
1326
+
1327
+ // 2. ui_setting_app (0x0C) — query
1328
+ val uiSettW = ProtobufWriter()
1329
+ uiSettW.writeInt32Field(
1330
+ 1,
1331
+ 2
1332
+ ) // cmd = DeviceReceiveRequest
1333
+ uiSettW.writeInt32Field(
1334
+ 2,
1335
+ sendManager.nextMagicRandom()
1336
+ )
1337
+ uiSettW.writeMessageField(
1338
+ 4,
1339
+ byteArrayOf(0x08, 0x01, 0x10, 0x00)
1340
+ ) // {1:1, 2:0}
1341
+ sendToGlasses(
1342
+ sendManager.buildPackets(
1343
+ serviceId = 0x0C,
1344
+ payload =
1345
+ uiSettW.toByteArray(),
1346
+ reserveFlag = true
1347
+ )
1348
+ )
1349
+
1350
+ // 3. teleprompter (0x10) — config (cmd=1,
1351
+ // field3={1:4})
1352
+ val teleW = ProtobufWriter()
1353
+ teleW.writeInt32Field(1, 1)
1354
+ teleW.writeInt32Field(
1355
+ 2,
1356
+ sendManager.nextMagicRandom()
1357
+ )
1358
+ teleW.writeMessageField(
1359
+ 3,
1360
+ byteArrayOf(0x08, 0x04)
1361
+ ) // {1:4}
1362
+ sendToGlasses(
1363
+ sendManager.buildPackets(
1364
+ serviceId = 0x10,
1365
+ payload =
1366
+ teleW.toByteArray(),
1367
+ reserveFlag = true
1368
+ )
1369
+ )
1370
+
1371
+ // 4. EvenHub CTRL on service 0x81 (cmd=1,
1372
+ // empty field3)
1373
+ val ehCtrlW = ProtobufWriter()
1374
+ ehCtrlW.writeInt32Field(1, 1)
1375
+ ehCtrlW.writeInt32Field(
1376
+ 2,
1377
+ sendManager.nextMagicRandom()
1378
+ )
1379
+ ehCtrlW.writeMessageField(3, ByteArray(0))
1380
+ sendEvenHubCtrlCommand(
1381
+ ehCtrlW.toByteArray()
1382
+ )
1383
+
1384
+ // 5. calendar (0x04) — config
1385
+ val calW = ProtobufWriter()
1386
+ calW.writeInt32Field(1, 1)
1387
+ calW.writeInt32Field(
1388
+ 2,
1389
+ sendManager.nextMagicRandom()
1390
+ )
1391
+ calW.writeMessageField(
1392
+ 3,
1393
+ byteArrayOf(
1394
+ 0x08,
1395
+ 0x01,
1396
+ 0x10,
1397
+ 0x01,
1398
+ 0x18,
1399
+ 0x05,
1400
+ 0x28,
1401
+ 0x01
1402
+ )
1403
+ )
1404
+ sendToGlasses(
1405
+ sendManager.buildPackets(
1406
+ serviceId = 0x04,
1407
+ payload =
1408
+ calW.toByteArray(),
1409
+ reserveFlag = true
1410
+ )
1411
+ )
1412
+
1413
+ // 6. Dashboard init (0x01) — display
1414
+ // settings
1415
+ val dashDisplayW = ProtobufWriter()
1416
+ dashDisplayW.writeInt32Field(
1417
+ 1,
1418
+ 4
1419
+ ) // displayMode
1420
+ dashDisplayW.writeInt32Field(
1421
+ 2,
1422
+ 3
1423
+ ) // statusDisplayCount
1424
+ dashDisplayW.writeMessageField(
1425
+ 3,
1426
+ byteArrayOf(1, 2, 3)
1427
+ ) // statusDisplayOrder
1428
+ dashDisplayW.writeInt32Field(
1429
+ 4,
1430
+ 4
1431
+ ) // widgetDisplayCount
1432
+ dashDisplayW.writeMessageField(
1433
+ 5,
1434
+ byteArrayOf(1, 3, 2, 2)
1435
+ ) // widgetDisplayOrder
1436
+ dashDisplayW.writeInt32Field(
1437
+ 6,
1438
+ 1
1439
+ ) // halfDayFormat
1440
+ dashDisplayW.writeInt32Field(
1441
+ 7,
1442
+ 1
1443
+ ) // temperatureUnit
1444
+
1445
+ val dashRecvW = ProtobufWriter()
1446
+ dashRecvW.writeMessageField(
1447
+ 2,
1448
+ dashDisplayW.toByteArray()
1449
+ )
1450
+
1451
+ val dashPkgW = ProtobufWriter()
1452
+ dashPkgW.writeInt32Field(
1453
+ 1,
1454
+ 2
1455
+ ) // Dashboard_Receive
1456
+ dashPkgW.writeInt32Field(
1457
+ 2,
1458
+ sendManager.nextMagicRandom()
1459
+ )
1460
+ dashPkgW.writeMessageField(
1461
+ 4,
1462
+ dashRecvW.toByteArray()
1463
+ )
1464
+ sendDashboardCommand(dashPkgW.toByteArray())
1465
+
1466
+ // 7. Dashboard REQUEST_NEWS_INFO (cmd=5,
1467
+ // field7={1:1})
1468
+ val dashNewsReqW = ProtobufWriter()
1469
+ dashNewsReqW.writeInt32Field(
1470
+ 1,
1471
+ 5
1472
+ ) // REQUEST_NEWS_INFO
1473
+ dashNewsReqW.writeInt32Field(
1474
+ 2,
1475
+ sendManager.nextMagicRandom()
1476
+ )
1477
+ dashNewsReqW.writeMessageField(
1478
+ 7,
1479
+ byteArrayOf(0x08, 0x01)
1480
+ ) // {1:1}
1481
+ sendDashboardCommand(
1482
+ dashNewsReqW.toByteArray()
1483
+ )
1484
+
1485
+ // 8. Gesture control list via g2_setting
1486
+ val gestListW = ProtobufWriter()
1487
+ gestListW.writeInt32Field(
1488
+ 1,
1489
+ 1
1490
+ ) // DeviceReceiveInfo
1491
+ gestListW.writeInt32Field(
1492
+ 2,
1493
+ sendManager.nextMagicRandom()
1494
+ )
1495
+ // field 3 with field 10
1496
+ // (gestureControlList): 3 items, all
1497
+ // app_unable
1498
+ val gestureCtrlPayload =
1499
+ byteArrayOf(
1500
+ 0x52,
1501
+ 0x18, // field 10, length 24
1502
+ 0x0A,
1503
+ 0x06,
1504
+ 0x08,
1505
+ 0x00,
1506
+ 0x10,
1507
+ 0x00,
1508
+ 0x18,
1509
+ 0x00, // item 1
1510
+ 0x0A,
1511
+ 0x06,
1512
+ 0x08,
1513
+ 0x00,
1514
+ 0x10,
1515
+ 0x01,
1516
+ 0x18,
1517
+ 0x00, // item 2
1518
+ 0x0A,
1519
+ 0x06,
1520
+ 0x08,
1521
+ 0x00,
1522
+ 0x10,
1523
+ 0x02,
1524
+ 0x18,
1525
+ 0x00 // item 3
1526
+ )
1527
+ gestListW.writeMessageField(
1528
+ 3,
1529
+ gestureCtrlPayload
1530
+ )
1531
+ sendG2SettingCommand(
1532
+ gestListW.toByteArray()
1533
+ )
1534
+
1535
+ // 9. Dashboard APP_REQUEST_NEWS_INFO
1536
+ // (cmd=7, field9={1:1})
1537
+ val dashAppNewsW = ProtobufWriter()
1538
+ dashAppNewsW.writeInt32Field(
1539
+ 1,
1540
+ 7
1541
+ ) // APP_REQUEST_NEWS_INFO
1542
+ dashAppNewsW.writeInt32Field(
1543
+ 2,
1544
+ sendManager.nextMagicRandom()
1545
+ )
1546
+ dashAppNewsW.writeMessageField(
1547
+ 9,
1548
+ byteArrayOf(0x08, 0x01)
1549
+ ) // {1:1}
1550
+ sendDashboardCommand(
1551
+ dashAppNewsW.toByteArray()
1552
+ )
1553
+
1554
+ Bridge.log(
1555
+ "G2: Sent full Even-compatible init sequence"
1556
+ )
1557
+ },
1558
+ 200
1559
+ )
1560
+
1561
+ // Start heartbeats after auth
1562
+ startHeartbeats()
1563
+
1564
+ // Mark as ready and request device info
1565
+ mainHandler.postDelayed(
1566
+ {
1567
+ reconnectionManager.stop()
1568
+ Bridge.log(
1569
+ "G2: Auth sequence complete, glasses ready"
1570
+ )
1571
+
1572
+ // Set device_name so DeviceManager can save
1573
+ // it for reconnection
1574
+ val peripheralName =
1575
+ rightGatt?.device?.name
1576
+ ?: leftGatt?.device?.name
1577
+ val serialNumber =
1578
+ peripheralName?.let {
1579
+ deviceNameToSerialNumber[it]
1580
+ }
1581
+ if (serialNumber != null) {
1582
+ DeviceStore.apply(
1583
+ "bluetooth",
1584
+ "device_name",
1585
+ serialNumber
1586
+ )
1587
+ Bridge.log(
1588
+ "G2: Set device_name to $serialNumber"
1589
+ )
1590
+ }
1591
+
1592
+ // Set bluetooth name and device model for
1593
+ // Device Info page
1594
+ val btName =
1595
+ rightGatt?.device?.name
1596
+ ?: leftGatt?.device?.name
1597
+ ?: ""
1598
+ DeviceStore.apply(
1599
+ "glasses",
1600
+ "bluetoothName",
1601
+ btName
1602
+ )
1603
+ DeviceStore.apply(
1604
+ "glasses",
1605
+ "deviceModel",
1606
+ DeviceTypes.G2
1607
+ )
1608
+
1609
+ setFullyConnected()
1610
+
1611
+ // Connect a controller if we have one
1612
+ connectController()
1613
+
1614
+ // Query version + battery info from glasses
1615
+ requestDeviceInfo()
1616
+
1617
+ sendMenuApps()
1618
+ },
1619
+ 500
1620
+ )
1621
+ },
1622
+ 200
1623
+ )
1624
+ },
1625
+ 200
1626
+ )
1627
+ },
1628
+ 200
1629
+ )
1630
+ }
1631
+
1632
+ private fun runDashboardSequence() {
1633
+ Bridge.log("G2: Running dashboard sequence")
1634
+
1635
+ // Send the shutdown command to the glasses
1636
+ val msg = EvenHubProto.shutdownMessage()
1637
+ sendEvenHubCommand(msg)
1638
+ pageCreated = false
1639
+ currentTextContent = ""
1640
+
1641
+ mainHandler.postDelayed(
1642
+ {
1643
+ // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1644
+ val gestureInitW = ProtobufWriter()
1645
+ gestureInitW.writeInt32Field(1, 0)
1646
+ gestureInitW.writeInt32Field(2, sendManager.nextMagicRandom())
1647
+ sendGestureCtrlCommand(gestureInitW.toByteArray())
1648
+
1649
+ // 6. Dashboard init (0x01) — display settings
1650
+ val dashDisplayW = ProtobufWriter()
1651
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1652
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1653
+ dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1654
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1655
+ dashDisplayW.writeMessageField(5, byteArrayOf(1, 3, 2, 2)) // widgetDisplayOrder
1656
+ dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1657
+ dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1658
+
1659
+ val dashRecvW = ProtobufWriter()
1660
+ dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
1661
+
1662
+ val dashPkgW = ProtobufWriter()
1663
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1664
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
1665
+ dashPkgW.writeMessageField(4, dashRecvW.toByteArray())
1666
+ sendDashboardCommand(dashPkgW.toByteArray())
1667
+
1668
+ Bridge.log("G2: Sent full Even-compatible init sequence")
1669
+ },
1670
+ 1000
1671
+ )
1672
+ }
1673
+
1674
+ // ---------- Heartbeats ----------
1675
+
1676
+ private fun startHeartbeats() {
1677
+ // EvenHub heartbeat every 5 seconds
1678
+ stopHeartbeats()
1679
+
1680
+ val hbRunnable =
1681
+ object : Runnable {
1682
+ override fun run() {
1683
+ sendEvenHubHeartbeat()
1684
+ mainHandler.postDelayed(this, 5000)
1685
+ }
1686
+ }
1687
+ heartbeatRunnable = hbRunnable
1688
+ mainHandler.postDelayed(hbRunnable, 5000)
1689
+
1690
+ // DevSettings heartbeat every 5 seconds
1691
+ val dsRunnable =
1692
+ object : Runnable {
1693
+ override fun run() {
1694
+ sendDevSettingsHeartbeat()
1695
+ mainHandler.postDelayed(this, 5000)
1696
+ }
1697
+ }
1698
+ devSettingsHeartbeatRunnable = dsRunnable
1699
+ mainHandler.postDelayed(dsRunnable, 5000)
1700
+
1701
+ // EvenHub text command queue: drain the most recent pending updateText every 100ms
1702
+ val queueRunnable =
1703
+ object : Runnable {
1704
+ override fun run() {
1705
+ drainEvenHubQueue()
1706
+ mainHandler.postDelayed(this, EVEN_HUB_QUEUE_TICK_MS)
1707
+ }
1708
+ }
1709
+ evenHubQueueRunnable = queueRunnable
1710
+ mainHandler.postDelayed(queueRunnable, EVEN_HUB_QUEUE_TICK_MS)
1711
+ }
1712
+
1713
+ private fun stopHeartbeats() {
1714
+ heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
1715
+ heartbeatRunnable = null
1716
+ devSettingsHeartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
1717
+ devSettingsHeartbeatRunnable = null
1718
+ evenHubQueueRunnable?.let { mainHandler.removeCallbacks(it) }
1719
+ evenHubQueueRunnable = null
1720
+ pendingTextMsg = null
1721
+ lastEvenHubMsg = null
1722
+ lastEvenHubResendsRemaining = 0
1723
+ }
1724
+
1725
+ private fun sendEvenHubHeartbeat() {
1726
+ val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
1727
+ if (!isFullyBooted) {
1728
+ return
1729
+ }
1730
+ val msg = EvenHubProto.heartbeatMessage()
1731
+ sendEvenHubCommand(msg)
1732
+
1733
+ // Poll battery every 10 heartbeats (~50 seconds)
1734
+ heartbeatCounter++
1735
+ if (heartbeatCounter % 10 == 0) {
1736
+ requestDeviceInfo()
1737
+ }
1738
+ }
1739
+
1740
+ private fun sendDevSettingsHeartbeat() {
1741
+ val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
1742
+ if (!isFullyBooted) {
1743
+ return
1744
+ }
1745
+ val msg = DevSettingsProto.baseHeartbeat(sendManager.nextMagicRandom())
1746
+ sendDevSettingsCommand(msg)
1747
+ }
1748
+
1749
+ private fun requestDeviceInfo() {
1750
+ val msg = G2SettingProto.requestInfo(sendManager.nextMagicRandom())
1751
+ sendG2SettingCommand(msg)
1752
+ Bridge.log("G2: Requested device info (battery/version)")
1753
+ }
1754
+
1755
+ private fun sendMenuApps() {
1756
+ val menuItems =
1757
+ DeviceStore.get("bluetooth", "menu_apps") as? List<Map<String, Any>> ?: emptyList()
1758
+ if (menuItems.isNotEmpty()) {
1759
+ setDashboardMenu(menuItems)
1760
+ }
1761
+ }
1762
+
1763
+ // ---------- SGCManager: Display Control ----------
1764
+
1765
+ override fun sendTextWall(text: String) {
1766
+ // Bridge.log("G2: sendTextWall(${text.take(10)}...)")
1767
+
1768
+ if (text.isEmpty()) {
1769
+ clearDisplay()
1770
+ return
1771
+ }
1772
+
1773
+ if (!pageCreated || !pageHasTextContainer) {
1774
+ createPageWithText(text)
1775
+ } else {
1776
+ updateText(text)
1777
+ }
1778
+ }
1779
+
1780
+ override fun sendDoubleTextWall(top: String, bottom: String) {
1781
+ val combined = "$top\n$bottom"
1782
+ sendTextWall(combined)
1783
+ }
1784
+
1785
+ override fun clearDisplay() {
1786
+ Bridge.log("G2: clearDisplay()")
1787
+ if (pageCreated) {
1788
+ sendTextWall(" ")
1789
+ }
1790
+ }
1791
+
1792
+ override fun displayBitmap(base64ImageData: String): Boolean {
1793
+ currentBitmapBase64 = base64ImageData
1794
+ currentTextContent = ""
1795
+ return displayBitmapQuad(base64ImageData)
1796
+ }
1797
+
1798
+ private fun displayBitmapQuad(base64ImageData: String): Boolean {
1799
+ val rawData =
1800
+ Base64.decode(base64ImageData, Base64.DEFAULT)
1801
+ ?: run {
1802
+ Bridge.log("G2: displayBitmapQuad() - failed to decode base64")
1803
+ return false
1804
+ }
1805
+
1806
+ val tiles =
1807
+ renderAndSliceTo4Tiles(rawData)
1808
+ ?: run {
1809
+ Bridge.log(
1810
+ "G2: displayBitmapQuad() - failed to slice image into tiles"
1811
+ )
1812
+ return false
1813
+ }
1814
+
1815
+ // 2x2 grid of 200x100 tiles covering 400x200 (matches G2.swift:1729-1745)
1816
+ val container1 =
1817
+ EvenHubProto.imageContainerProperty(
1818
+ x = 0,
1819
+ y = 0,
1820
+ width = 200,
1821
+ height = 100,
1822
+ containerID = 10,
1823
+ containerName = "img-10"
1824
+ )
1825
+ val container2 =
1826
+ EvenHubProto.imageContainerProperty(
1827
+ x = 200,
1828
+ y = 0,
1829
+ width = 200,
1830
+ height = 100,
1831
+ containerID = 11,
1832
+ containerName = "img-11"
1833
+ )
1834
+ val container3 =
1835
+ EvenHubProto.imageContainerProperty(
1836
+ x = 0,
1837
+ y = 100,
1838
+ width = 200,
1839
+ height = 100,
1840
+ containerID = 12,
1841
+ containerName = "img-12"
1842
+ )
1843
+ val container4 =
1844
+ EvenHubProto.imageContainerProperty(
1845
+ x = 200,
1846
+ y = 100,
1847
+ width = 200,
1848
+ height = 100,
1849
+ containerID = 13,
1850
+ containerName = "img-13"
1851
+ )
1852
+ val containers = listOf(container1, container2, container3, container4)
1853
+
1854
+ val msg: ByteArray =
1855
+ if (!startupPageCreated) {
1856
+ startupPageCreated = true
1857
+ EvenHubProto.createPageMessage(
1858
+ imageContainers = containers,
1859
+ magicRandom = sendManager.nextMagicRandom(),
1860
+ appId = activeMenuAppId
1861
+ )
1862
+ } else {
1863
+ EvenHubProto.rebuildPageMessage(
1864
+ imageContainers = containers,
1865
+ magicRandom = sendManager.nextMagicRandom(),
1866
+ appId = activeMenuAppId
1867
+ )
1868
+ }
1869
+ sendEvenHubCommand(msg)
1870
+ pageCreated = true
1871
+ pageHasTextContainer = false
1872
+ currentTextContent = ""
1873
+
1874
+ // After the 1s settle delay iOS waits, send each tile's BMP in series. Android's
1875
+ // displayBitmap signature is synchronous Boolean, so this is fire-and-forget — we
1876
+ // chain tiles via callbacks rather than awaiting like iOS.
1877
+ Bridge.log("G2: displayBitmapQuad() - page sent, scheduling fragment send in 1s...")
1878
+ mainHandler.postDelayed(
1879
+ {
1880
+ sendImageDataChained(
1881
+ tiles =
1882
+ listOf(
1883
+ Triple(10, "img-10", tiles[0]),
1884
+ Triple(11, "img-11", tiles[1]),
1885
+ Triple(12, "img-12", tiles[2]),
1886
+ Triple(13, "img-13", tiles[3])
1887
+ ),
1888
+ index = 0
1889
+ )
1890
+ },
1891
+ 1000
1892
+ )
1893
+
1894
+ return true
1895
+ }
1896
+
1897
+ /** Send the next tile's BMP in series; recurse to the next tile after this one finishes. */
1898
+ private fun sendImageDataChained(
1899
+ tiles: List<Triple<Int, String, ByteArray>>,
1900
+ index: Int
1901
+ ) {
1902
+ if (index >= tiles.size) return
1903
+ val (containerID, containerName, bmpData) = tiles[index]
1904
+ sendImageData(containerID, containerName, bmpData) {
1905
+ sendImageDataChained(tiles, index + 1)
1906
+ }
1907
+ }
1908
+
1909
+ private fun sendImageData(
1910
+ containerID: Int,
1911
+ containerName: String,
1912
+ bmpData: ByteArray,
1913
+ onComplete: (() -> Unit)? = null
1914
+ ) {
1915
+ val fragmentSize = 4096
1916
+ imageSessionCounter++
1917
+ val sessionId = imageSessionCounter
1918
+ val totalSize = bmpData.size
1919
+ var fragmentIndex = 0
1920
+ var offset = 0
1921
+
1922
+ fun sendNextFragment() {
1923
+ if (offset >= bmpData.size) {
1924
+ Bridge.log(
1925
+ "G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes"
1926
+ )
1927
+ onComplete?.invoke()
1928
+ return
1929
+ }
1930
+
1931
+ val end = minOf(offset + fragmentSize, bmpData.size)
1932
+ val fragment = bmpData.copyOfRange(offset, end)
1933
+
1934
+ val msg =
1935
+ EvenHubProto.updateImageRawDataMessage(
1936
+ containerID = containerID,
1937
+ containerName = containerName,
1938
+ mapSessionId = sessionId,
1939
+ mapTotalSize = totalSize,
1940
+ compressMode = 0,
1941
+ mapFragmentIndex = fragmentIndex,
1942
+ mapFragmentPacketSize = fragment.size,
1943
+ mapRawData = fragment
1944
+ )
1945
+ sendEvenHubCommand(msg)
1946
+ Bridge.log("G2: sendImageData($containerName) - sent fragment $fragmentIndex")
1947
+
1948
+ fragmentIndex++
1949
+ offset = end
1950
+
1951
+ // 200ms between fragments — and also before onComplete, so the next tile in
1952
+ // sendImageDataChained gets the same gap before its first fragment (matches iOS,
1953
+ // which awaits 200ms after every fragment including the last).
1954
+ if (offset < bmpData.size) {
1955
+ mainHandler.postDelayed({ sendNextFragment() }, 200)
1956
+ } else {
1957
+ Bridge.log(
1958
+ "G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes"
1959
+ )
1960
+ mainHandler.postDelayed({ onComplete?.invoke() }, 200)
1961
+ }
1962
+ }
1963
+
1964
+ sendNextFragment()
1965
+ }
1966
+
1967
+ /**
1968
+ * Render any image to 400x200 grayscale, then slice into 4 tiles (200x100 each).
1969
+ * Returns 4 BMP ByteArrays: [top-left, top-right, bottom-left, bottom-right].
1970
+ * Mirrors G2.swift renderAndSliceTo4Tiles.
1971
+ */
1972
+ private fun renderAndSliceTo4Tiles(data: ByteArray): List<ByteArray>? {
1973
+ val srcBitmap =
1974
+ BitmapFactory.decodeByteArray(data, 0, data.size)
1975
+ ?: run {
1976
+ Bridge.log("G2: renderAndSliceTo4Tiles - could not decode image")
1977
+ return null
1978
+ }
1979
+
1980
+ val srcWidth = srcBitmap.width
1981
+ val srcHeight = srcBitmap.height
1982
+ val tileWidth = 200
1983
+ val tileHeight = 100
1984
+ val totalW = tileWidth * 2 // 400
1985
+ val totalH = tileHeight * 2 // 200
1986
+
1987
+ // Scale source to fit within 400x200 (maintain aspect ratio)
1988
+ val scale = minOf(totalW.toDouble() / srcWidth, totalH.toDouble() / srcHeight)
1989
+ val scaledW = maxOf(1, (srcWidth * scale).toInt())
1990
+ val scaledH = maxOf(1, (srcHeight * scale).toInt())
1991
+ val offsetX = (totalW - scaledW) / 2
1992
+ val offsetY = (totalH - scaledH) / 2
1993
+
1994
+ Bridge.log(
1995
+ "G2: renderAndSliceTo4Tiles - input ${srcWidth}x${srcHeight} → ${scaledW}x${scaledH} in ${totalW}x${totalH}"
1996
+ )
1997
+
1998
+ // Render to 400x200 with black background
1999
+ val destBitmap = Bitmap.createBitmap(totalW, totalH, Bitmap.Config.ARGB_8888)
2000
+ val canvas = Canvas(destBitmap)
2001
+ canvas.drawColor(Color.BLACK)
2002
+ val srcRect = Rect(0, 0, srcWidth, srcHeight)
2003
+ val dstRect = Rect(offsetX, offsetY, offsetX + scaledW, offsetY + scaledH)
2004
+ val paint = Paint(Paint.FILTER_BITMAP_FLAG)
2005
+ canvas.drawBitmap(srcBitmap, srcRect, dstRect, paint)
2006
+ srcBitmap.recycle()
2007
+
2008
+ // Pull a single 400x200 grayscale buffer once; slice each tile from it
2009
+ val fullPixels = ByteArray(totalW * totalH)
2010
+ val argbRow = IntArray(totalW)
2011
+ for (y in 0 until totalH) {
2012
+ destBitmap.getPixels(argbRow, 0, totalW, 0, y, totalW, 1)
2013
+ val rowOffset = y * totalW
2014
+ for (x in 0 until totalW) {
2015
+ val pixel = argbRow[x]
2016
+ val r = (pixel shr 16) and 0xFF
2017
+ val g = (pixel shr 8) and 0xFF
2018
+ val b = pixel and 0xFF
2019
+ fullPixels[rowOffset + x] = ((r * 299 + g * 587 + b * 114) / 1000).toByte()
2020
+ }
2021
+ }
2022
+ destBitmap.recycle()
2023
+
2024
+ // Slice into 4 tiles: top-left, top-right, bottom-left, bottom-right (matches iOS order)
2025
+ val tileOrigins = listOf(0 to 0, tileWidth to 0, 0 to tileHeight, tileWidth to tileHeight)
2026
+ val tiles = mutableListOf<ByteArray>()
2027
+ for ((ox, oy) in tileOrigins) {
2028
+ val tilePixels = ByteArray(tileWidth * tileHeight)
2029
+ for (row in 0 until tileHeight) {
2030
+ val srcRowStart = (oy + row) * totalW + ox
2031
+ System.arraycopy(
2032
+ fullPixels,
2033
+ srcRowStart,
2034
+ tilePixels,
2035
+ row * tileWidth,
2036
+ tileWidth
2037
+ )
2038
+ }
2039
+ val bmp =
2040
+ build4BitBmp(tilePixels, tileWidth, tileHeight)
2041
+ ?: run {
2042
+ Bridge.log(
2043
+ "G2: renderAndSliceTo4Tiles - failed to build BMP for tile"
2044
+ )
2045
+ return null
2046
+ }
2047
+ tiles.add(bmp)
2048
+ }
2049
+ return tiles
2050
+ }
2051
+
2052
+ override fun showDashboard() {
2053
+ // G2 doesn't have a native dashboard concept via EvenHub
2054
+ }
2055
+
2056
+ override fun setDashboardPosition(height: Int, depth: Int) {
2057
+ Bridge.log("G2: setDashboardPosition(height=$height, depth=$depth)")
2058
+ setDashboardHeightOnly(height)
2059
+ setDashboardDepthOnly(depth)
2060
+ }
2061
+
2062
+ override fun setDashboardHeightOnly(height: Int) {
2063
+ val clamped = height.coerceIn(0, 12)
2064
+ Bridge.log("G2: setDashboardHeightOnly($clamped)")
2065
+ val msg = G2SettingProto.setScreenHeight(sendManager.nextMagicRandom(), clamped)
2066
+ sendG2SettingCommand(msg)
2067
+ }
2068
+
2069
+ override fun setDashboardDepthOnly(depth: Int) {
2070
+ val clamped = depth.coerceIn(0, 2)
2071
+ Bridge.log("G2: setDashboardDepthOnly($clamped)")
2072
+ val msg = G2SettingProto.setScreenDepth(sendManager.nextMagicRandom(), clamped)
2073
+ sendG2SettingCommand(msg)
2074
+ }
2075
+
2076
+ override fun setDashboardMenu(items: List<Map<String, Any>>) {
2077
+ Bridge.log("G2: setDashboardMenu -- items: $items")
2078
+ val menuItems =
2079
+ items.mapNotNull { dict ->
2080
+ val name = dict["name"] as? String ?: return@mapNotNull null
2081
+ val packageName = dict["packageName"] as? String ?: return@mapNotNull null
2082
+ val running = dict["running"] as? Boolean ?: false
2083
+ MenuProto.MenuItem(packageName, name, running)
2084
+ }
2085
+ dashboardMenuItems.clear()
2086
+ dashboardMenuItems.addAll(menuItems)
2087
+ Bridge.log("G2: setDashboardMenu -- sending ${menuItems.size} items")
2088
+ val (msg, appIdMap) = MenuProto.sendMenuInfo(sendManager.nextMagicRandom(), menuItems)
2089
+ menuAppIdToPackageName = appIdMap.toMutableMap()
2090
+ activeMenuAppId = appIdMap.keys.sorted().firstOrNull()
2091
+ sendMenuCommand(msg)
2092
+ }
2093
+
2094
+ override fun setBrightness(level: Int, autoMode: Boolean) {
2095
+ Bridge.log("G2: setBrightness($level, auto=$autoMode)")
2096
+ val msg =
2097
+ G2SettingProto.setBrightness(
2098
+ magicRandom = sendManager.nextMagicRandom(),
2099
+ level = level,
2100
+ autoAdjust = autoMode
2101
+ )
2102
+ sendG2SettingCommand(msg)
2103
+ }
2104
+
2105
+ // ---------- Private Display Helpers ----------
2106
+
2107
+ private fun createPageWithText(text: String) {
2108
+ val tc =
2109
+ EvenHubProto.textContainerProperty(
2110
+ x = 0,
2111
+ y = 0,
2112
+ width = 576,
2113
+ height = 288,
2114
+ borderWidth = 0,
2115
+ borderColor = 0,
2116
+ borderRadius = 0,
2117
+ paddingLength = 4,
2118
+ containerID = textContainerID,
2119
+ containerName = "text-main",
2120
+ isEventCapture = true,
2121
+ content = text
2122
+ )
2123
+
2124
+ val msg: ByteArray
2125
+ if (!startupPageCreated) {
2126
+ Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2127
+ msg =
2128
+ EvenHubProto.createPageMessage(
2129
+ textContainers = listOf(tc),
2130
+ magicRandom = sendManager.nextMagicRandom(),
2131
+ appId = activeMenuAppId
2132
+ )
2133
+ startupPageCreated = true
2134
+ } else {
2135
+ Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2136
+ msg =
2137
+ EvenHubProto.rebuildPageMessage(
2138
+ textContainers = listOf(tc),
2139
+ magicRandom = sendManager.nextMagicRandom(),
2140
+ appId = activeMenuAppId
2141
+ )
2142
+ }
2143
+ sendEvenHubCommand(msg)
2144
+ pageCreated = true
2145
+ pageHasTextContainer = true
2146
+ currentTextContent = text
2147
+ currentBitmapBase64 = ""
2148
+ }
2149
+
2150
+ private fun updateText(text: String) {
2151
+ val msg =
2152
+ EvenHubProto.updateTextMessage(
2153
+ containerID = textContainerID,
2154
+ contentOffset = 0,
2155
+ contentLength = text.toByteArray(Charsets.UTF_8).size,
2156
+ content = text
2157
+ )
2158
+ queueEvenHubCommand(msg)
2159
+ currentTextContent = text
2160
+ currentBitmapBase64 = ""
2161
+ }
2162
+
2163
+ @Synchronized
2164
+ private fun queueEvenHubCommand(payload: ByteArray) {
2165
+ pendingTextMsg = payload
2166
+ }
2167
+
2168
+ @Synchronized
2169
+ private fun drainEvenHubQueue() {
2170
+ val msg = pendingTextMsg
2171
+ pendingTextMsg = null
2172
+ val toSend: ByteArray? = if (msg != null) {
2173
+ lastEvenHubMsg = msg
2174
+ lastEvenHubResendsRemaining = EVEN_HUB_RESEND_COUNT
2175
+ msg
2176
+ } else if (lastEvenHubResendsRemaining > 0 && lastEvenHubMsg != null) {
2177
+ lastEvenHubResendsRemaining -= 1
2178
+ lastEvenHubMsg
2179
+ } else {
2180
+ null
2181
+ }
2182
+ toSend?.let { sendEvenHubCommand(it) }
2183
+ }
2184
+
2185
+ // ---------- Bitmap Conversion ----------
2186
+
2187
+ private fun convertToG2Bmp(
2188
+ data: ByteArray,
2189
+ containerWidth: Int,
2190
+ containerHeight: Int
2191
+ ): ByteArray? {
2192
+ val srcBitmap =
2193
+ BitmapFactory.decodeByteArray(data, 0, data.size)
2194
+ ?: run {
2195
+ Bridge.log("G2: convertToG2Bmp - could not decode image")
2196
+ return null
2197
+ }
2198
+
2199
+ val srcWidth = srcBitmap.width
2200
+ val srcHeight = srcBitmap.height
2201
+
2202
+ // Scale to fit within container (maintain aspect ratio)
2203
+ val scale =
2204
+ minOf(containerWidth.toDouble() / srcWidth, containerHeight.toDouble() / srcHeight)
2205
+ val scaledW = maxOf(1, (srcWidth * scale).toInt())
2206
+ val scaledH = maxOf(1, (srcHeight * scale).toInt())
2207
+ val offsetX = (containerWidth - scaledW) / 2
2208
+ val offsetY = (containerHeight - scaledH) / 2
2209
+
2210
+ Bridge.log(
2211
+ "G2: convertToG2Bmp - input ${srcWidth}x${srcHeight} → scaled ${scaledW}x${scaledH} in ${containerWidth}x${containerHeight}"
2212
+ )
2213
+
2214
+ // Render to container-sized bitmap with black background
2215
+ val destBitmap =
2216
+ Bitmap.createBitmap(containerWidth, containerHeight, Bitmap.Config.ARGB_8888)
2217
+ val canvas = Canvas(destBitmap)
2218
+ canvas.drawColor(Color.BLACK)
2219
+
2220
+ val srcRect = Rect(0, 0, srcWidth, srcHeight)
2221
+ val dstRect = Rect(offsetX, offsetY, offsetX + scaledW, offsetY + scaledH)
2222
+ val paint = Paint(Paint.FILTER_BITMAP_FLAG)
2223
+ canvas.drawBitmap(srcBitmap, srcRect, dstRect, paint)
2224
+
2225
+ // Extract grayscale pixels
2226
+ val grayscalePixels = ByteArray(containerWidth * containerHeight)
2227
+ for (y in 0 until containerHeight) {
2228
+ for (x in 0 until containerWidth) {
2229
+ val pixel = destBitmap.getPixel(x, y)
2230
+ val r = Color.red(pixel)
2231
+ val g = Color.green(pixel)
2232
+ val b = Color.blue(pixel)
2233
+ val gray = (r * 299 + g * 587 + b * 114) / 1000
2234
+ grayscalePixels[y * containerWidth + x] = gray.toByte()
2235
+ }
2236
+ }
2237
+
2238
+ srcBitmap.recycle()
2239
+ destBitmap.recycle()
2240
+
2241
+ return build4BitBmp(grayscalePixels, containerWidth, containerHeight)
2242
+ }
2243
+
2244
+ private fun build4BitBmp(grayscalePixels: ByteArray, width: Int, height: Int): ByteArray? {
2245
+ // 4-bit: 2 pixels per byte, rows padded to 4-byte boundary
2246
+ val bytesPerRow4bit = (width + 1) / 2
2247
+ val paddedRowSize = (bytesPerRow4bit + 3) and 3.inv()
2248
+ val pixelDataSize = paddedRowSize * height
2249
+
2250
+ // BMP file header (14) + DIB header (40) + color table (16 * 4 = 64)
2251
+ val headerSize = 14 + 40 + 64
2252
+ val fileSize = headerSize + pixelDataSize
2253
+
2254
+ val bmp = ByteArrayOutputStream(fileSize)
2255
+
2256
+ // --- BMP File Header (14 bytes) ---
2257
+ bmp.write(0x42)
2258
+ bmp.write(0x4D) // "BM"
2259
+ writeLittleEndianInt(bmp, fileSize)
2260
+ writeLittleEndianShort(bmp, 0) // Reserved1
2261
+ writeLittleEndianShort(bmp, 0) // Reserved2
2262
+ writeLittleEndianInt(bmp, headerSize) // Pixel data offset
2263
+
2264
+ // --- DIB Header (BITMAPINFOHEADER, 40 bytes) ---
2265
+ writeLittleEndianInt(bmp, 40) // DIB header size
2266
+ writeLittleEndianInt(bmp, width) // Width
2267
+ writeLittleEndianInt(bmp, height) // Height (positive = bottom-up)
2268
+ writeLittleEndianShort(bmp, 1) // Color planes
2269
+ writeLittleEndianShort(bmp, 4) // Bits per pixel (4-bit)
2270
+ writeLittleEndianInt(bmp, 0) // Compression (none)
2271
+ writeLittleEndianInt(bmp, pixelDataSize) // Image size
2272
+ writeLittleEndianInt(bmp, 2835) // X pixels/meter (~72 DPI)
2273
+ writeLittleEndianInt(bmp, 2835) // Y pixels/meter
2274
+ writeLittleEndianInt(bmp, 16) // Colors used
2275
+ writeLittleEndianInt(bmp, 0) // Important colors (0 = all)
2276
+
2277
+ // --- Color Table (16 entries, 4 bytes each: B, G, R, 0) ---
2278
+ for (i in 0 until 16) {
2279
+ val v = i * 17 // 0, 17, 34, ... 255
2280
+ bmp.write(v)
2281
+ bmp.write(v)
2282
+ bmp.write(v)
2283
+ bmp.write(0) // B, G, R, Reserved
2284
+ }
2285
+
2286
+ // --- Pixel Data (bottom-up rows, 4-bit packed) ---
2287
+ for (row in 0 until height) {
2288
+ // BMP is bottom-up: row 0 in BMP = last row of image
2289
+ val srcRow = height - 1 - row
2290
+ val srcOffset = srcRow * width
2291
+ val rowBuf = ByteArray(paddedRowSize)
2292
+
2293
+ for (col in 0 until width) {
2294
+ val pixelIndex = srcOffset + col
2295
+ if (pixelIndex >= grayscalePixels.size) continue
2296
+
2297
+ val gray8 = grayscalePixels[pixelIndex].toInt() and 0xFF
2298
+ val index4 = gray8 shr 4
2299
+
2300
+ val bytePos = col / 2
2301
+ if (col % 2 == 0) {
2302
+ rowBuf[bytePos] = (index4 shl 4).toByte()
2303
+ } else {
2304
+ rowBuf[bytePos] = (rowBuf[bytePos].toInt() or index4).toByte()
2305
+ }
2306
+ }
2307
+ bmp.write(rowBuf)
2308
+ }
2309
+
2310
+ Bridge.log(
2311
+ "G2: build4BitBmp - ${bmp.size()} bytes (header=$headerSize, pixels=$pixelDataSize, rows=${paddedRowSize}x$height)"
2312
+ )
2313
+ return bmp.toByteArray()
2314
+ }
2315
+
2316
+ private fun writeLittleEndianInt(out: ByteArrayOutputStream, value: Int) {
2317
+ out.write(value and 0xFF)
2318
+ out.write((value shr 8) and 0xFF)
2319
+ out.write((value shr 16) and 0xFF)
2320
+ out.write((value shr 24) and 0xFF)
2321
+ }
2322
+
2323
+ private fun writeLittleEndianShort(out: ByteArrayOutputStream, value: Int) {
2324
+ out.write(value and 0xFF)
2325
+ out.write((value shr 8) and 0xFF)
2326
+ }
2327
+
2328
+ // ---------- SGCManager: Audio Control ----------
2329
+
2330
+ override fun setMicEnabled(enabled: Boolean) {
2331
+ Bridge.log("G2: setMicEnabled($enabled)")
2332
+ val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2333
+
2334
+ // if already enabled, set to disabled, then send enabled after 500ms:
2335
+ if (currentEnabled && enabled) {
2336
+ DeviceStore.apply("glasses", "micEnabled", true)
2337
+ val msg = EvenHubProto.audioControlMessage(false)
2338
+ sendEvenHubCommand(msg)
2339
+ mainHandler.postDelayed({
2340
+ val msg = EvenHubProto.audioControlMessage(true)
2341
+ sendEvenHubCommand(msg)
2342
+ }, 500)
2343
+ return
2344
+ }
2345
+ DeviceStore.apply("glasses", "micEnabled", enabled)
2346
+ val msg = EvenHubProto.audioControlMessage(enabled)
2347
+ sendEvenHubCommand(msg)
2348
+ }
2349
+
2350
+ override fun sortMicRanking(list: MutableList<String>): MutableList<String> {
2351
+ return list
2352
+ }
2353
+
2354
+ // Camera & Media - G2 has no camera
2355
+ override fun requestPhoto(
2356
+ requestId: String,
2357
+ appId: String,
2358
+ size: String,
2359
+ webhookUrl: String?,
2360
+ authToken: String?,
2361
+ compress: String?,
2362
+ flash: Boolean,
2363
+ sound: Boolean
2364
+ ) {
2365
+ Bridge.log("G2: requestPhoto - not supported (no camera)")
2366
+ }
2367
+
2368
+ override fun startStream(message: MutableMap<String, Any>) {
2369
+ Bridge.log("G2: startStream - not supported")
2370
+ }
2371
+
2372
+ override fun stopStream() {
2373
+ Bridge.log("G2: stopStream - not supported")
2374
+ }
2375
+
2376
+ override fun sendStreamKeepAlive(message: MutableMap<String, Any>) {
2377
+ Bridge.log("G2: sendStreamKeepAlive - not supported")
2378
+ }
2379
+
2380
+ override fun startVideoRecording(
2381
+ requestId: String,
2382
+ save: Boolean,
2383
+ flash: Boolean,
2384
+ sound: Boolean
2385
+ ) {
2386
+ Bridge.log("G2: startVideoRecording - not supported")
2387
+ }
2388
+
2389
+ override fun stopVideoRecording(requestId: String) {
2390
+ Bridge.log("G2: stopVideoRecording - not supported")
2391
+ }
2392
+
2393
+ // Button Settings
2394
+ override fun sendButtonPhotoSettings() {
2395
+ Bridge.log("G2: sendButtonPhotoSettings")
2396
+ }
2397
+
2398
+ override fun sendButtonVideoRecordingSettings() {
2399
+ Bridge.log("G2: sendButtonVideoRecordingSettings")
2400
+ }
2401
+
2402
+ override fun sendButtonMaxRecordingTime() {
2403
+ Bridge.log("G2: sendButtonMaxRecordingTime")
2404
+ }
2405
+
2406
+ override fun sendButtonCameraLedSetting() {
2407
+ Bridge.log("G2: sendButtonCameraLedSetting")
2408
+ }
2409
+
2410
+ override fun sendCameraFovSetting() {
2411
+ Bridge.log("G2: sendCameraFovSetting")
2412
+ }
2413
+
2414
+ override fun findCompatibleDevices() {
2415
+ Bridge.log("G2: findCompatibleDevices()")
2416
+ DEVICE_SEARCH_ID = "NOT_SET"
2417
+ startScan()
2418
+ }
2419
+
2420
+ override fun connectById(id: String) {
2421
+ Bridge.log("G2: connectById($id)")
2422
+ DEVICE_SEARCH_ID = id
2423
+ startScan()
2424
+ startPairingTimeout()
2425
+ }
2426
+
2427
+ private fun startPairingTimeout() {
2428
+ pairingTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
2429
+ val work = Runnable {
2430
+ if (leftGatt != null && rightGatt == null) {
2431
+ Bridge.log("G2: pairing timeout — found LEFT but not RIGHT")
2432
+ Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
2433
+ }
2434
+ }
2435
+ pairingTimeoutRunnable = work
2436
+ mainHandler.postDelayed(work, 10_000)
2437
+ }
2438
+
2439
+ private fun cancelPairingTimeout() {
2440
+ pairingTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
2441
+ pairingTimeoutRunnable = null
2442
+ }
2443
+
2444
+ override fun disconnect() {
2445
+ Bridge.log("G2: disconnect()")
2446
+ isDisconnecting = true
2447
+ cancelPairingTimeout()
2448
+ stopScan()
2449
+ stopHeartbeats()
2450
+ reconnectionManager.stop()
2451
+
2452
+ leftGatt?.disconnect()
2453
+ leftGatt?.close()
2454
+ rightGatt?.disconnect()
2455
+ rightGatt?.close()
2456
+
2457
+ leftInitialized = false
2458
+ rightInitialized = false
2459
+ authStarted = false
2460
+ leftAuthenticated = false
2461
+ rightAuthenticated = false
2462
+ startupPageCreated = false
2463
+ pageCreated = false
2464
+ pageHasTextContainer = false
2465
+ heartbeatCounter = 0
2466
+ currentBitmapBase64 = ""
2467
+ menuAppIdToPackageName.clear()
2468
+ activeMenuAppId = null
2469
+ lastClickTimestamp = null
2470
+ lastMenuSelectTimestamp = null
2471
+ DeviceStore.apply("glasses", "connected", false)
2472
+ DeviceStore.apply("glasses", "fullyBooted", false)
2473
+ }
2474
+
2475
+ override fun forget() {
2476
+ Bridge.log("G2: forget()")
2477
+ stopHeartbeats()
2478
+ reconnectionManager.stop()
2479
+ disconnect()
2480
+ leftGlassAddress = null
2481
+ rightGlassAddress = null
2482
+ leftGatt = null
2483
+ rightGatt = null
2484
+ leftWriteChar = null
2485
+ rightWriteChar = null
2486
+ leftNotifyChar = null
2487
+ rightNotifyChar = null
2488
+ leftAudioChar = null
2489
+ rightAudioChar = null
2490
+ DEVICE_SEARCH_ID = "NOT_SET"
2491
+ dashboardMenuItems.clear()
2492
+ }
2493
+
2494
+ override fun cleanup() {
2495
+ disconnect()
2496
+ }
2497
+
2498
+ override fun getConnectedBluetoothName(): String {
2499
+ return rightGatt?.device?.name ?: leftGatt?.device?.name ?: ""
2500
+ }
2501
+
2502
+ override fun ping() {
2503
+ sendEvenHubHeartbeat()
2504
+ }
2505
+
2506
+ override fun dbg1() {
2507
+ connectController()
2508
+ }
2509
+ override fun dbg2() {
2510
+ disconnectController()
2511
+ }
2512
+
2513
+ fun reconnectController() {
2514
+ val mac = DeviceStore.get("glasses", "controllerMacAddress") as? String
2515
+ if (mac.isNullOrEmpty()) {
2516
+ Bridge.log("G2: reconnectController - no MAC address found")
2517
+ return
2518
+ }
2519
+ connectController()
2520
+ }
2521
+
2522
+ override fun connectController() {
2523
+ val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
2524
+ if (!isFullyBooted) {
2525
+ Bridge.log("G2: connectController - g2 not fully booted, ignoring")
2526
+ return
2527
+ }
2528
+ val mac = DeviceStore.get("glasses", "controllerMacAddress") as? String
2529
+ if (mac.isNullOrEmpty()) {
2530
+ Bridge.log("G2: connectController - no MAC address found")
2531
+ return
2532
+ }
2533
+ val hexParts = mac.split(":").mapNotNull { it.toIntOrNull(16)?.toByte() }
2534
+ if (hexParts.size != 6) {
2535
+ Bridge.log("G2: connectController - invalid MAC format: $mac")
2536
+ return
2537
+ }
2538
+ Bridge.log("G2: connectController() - MAC: $mac")
2539
+ val macData = hexParts.toByteArray()
2540
+ val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), true, macData)
2541
+ sendDevSettingsCommand(msg)
2542
+ Bridge.log("G2: Sent RING_CONNECT_INFO for MAC $mac")
2543
+ }
2544
+
2545
+ override fun disconnectController() {
2546
+ val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
2547
+ if (!isFullyBooted) {
2548
+ Bridge.log("G2: disconnectController - g2 not fully booted, ignoring")
2549
+ return
2550
+ }
2551
+ val mac = DeviceStore.get("glasses", "controllerMacAddress") as? String
2552
+ if (mac.isNullOrEmpty()) {
2553
+ Bridge.log("G2: disconnectController - no MAC address found")
2554
+ return
2555
+ }
2556
+ val hexParts = mac.split(":").mapNotNull { it.toIntOrNull(16)?.toByte() }
2557
+ if (hexParts.size != 6) {
2558
+ Bridge.log("G2: disconnectController - invalid MAC format: $mac")
2559
+ return
2560
+ }
2561
+ val macData = hexParts.toByteArray()
2562
+ val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), false, macData)
2563
+ sendDevSettingsCommand(msg)
2564
+ // DeviceStore.apply("glasses", "controllerMacAddress", "")
2565
+ DeviceStore.apply("glasses", "controllerConnected", false)
2566
+ DeviceStore.apply("glasses", "controllerFullyBooted", false)
2567
+ Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC $mac")
2568
+ }
2569
+
2570
+ // ---------- SGCManager: Device Control ----------
2571
+
2572
+ override fun setHeadUpAngle(angle: Int) {
2573
+ val clamped = angle.coerceIn(0, 60)
2574
+ Bridge.log("G2: setHeadUpAngle($clamped)")
2575
+
2576
+ // Enable head-up display
2577
+ val enableMsg = G2SettingProto.setHeadUpSwitch(sendManager.nextMagicRandom(), true)
2578
+ sendG2SettingCommand(enableMsg)
2579
+
2580
+ // Set the angle
2581
+ val angleMsg = G2SettingProto.setHeadUpAngle(sendManager.nextMagicRandom(), clamped)
2582
+ sendG2SettingCommand(angleMsg)
2583
+ }
2584
+
2585
+ override fun getBatteryStatus() {
2586
+ Bridge.log("G2: getBatteryStatus()")
2587
+ requestDeviceInfo()
2588
+ }
2589
+
2590
+ override fun setSilentMode(enabled: Boolean) {
2591
+ // TODO: Implement
2592
+ }
2593
+
2594
+ override fun exit() {
2595
+ clearDisplay()
2596
+ }
2597
+
2598
+ override fun sendShutdown() {
2599
+ clearDisplay()
2600
+ disconnect()
2601
+ }
2602
+
2603
+ override fun sendReboot() {
2604
+ // TODO: Implement via dev_settings
2605
+ }
2606
+
2607
+ override fun sendRgbLedControl(
2608
+ requestId: String,
2609
+ packageName: String?,
2610
+ action: String,
2611
+ color: String?,
2612
+ ontime: Int,
2613
+ offtime: Int,
2614
+ count: Int
2615
+ ) {
2616
+ // G2 doesn't have RGB LEDs
2617
+ Bridge.sendRgbLedControlResponse(requestId, false, "device_not_supported")
2618
+ }
2619
+
2620
+ // ---------- SGCManager: Network (G2 has no WiFi) ----------
2621
+
2622
+ override fun requestWifiScan() {}
2623
+ override fun sendWifiCredentials(ssid: String, password: String) {}
2624
+ override fun forgetWifiNetwork(ssid: String) {}
2625
+ override fun sendHotspotState(enabled: Boolean) {}
2626
+
2627
+ // ---------- SGCManager: User Context ----------
2628
+
2629
+ override fun sendUserEmailToGlasses(email: String) {
2630
+ // TODO: Could send via dev_settings
2631
+ }
2632
+
2633
+ // ---------- SGCManager: Gallery ----------
2634
+
2635
+ override fun queryGalleryStatus() {}
2636
+ override fun sendGalleryMode() {}
2637
+
2638
+ // ---------- SGCManager: Version Info ----------
2639
+
2640
+ override fun requestVersionInfo() {
2641
+ Bridge.log("G2: requestVersionInfo()")
2642
+ requestDeviceInfo()
2643
+ }
2644
+
2645
+ override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {}
2646
+
2647
+ // ---------- BLE Scanning ----------
2648
+
2649
+ private fun startScan(): Boolean {
2650
+ Bridge.log("G2: startScan()")
2651
+
2652
+ stopScan()
2653
+
2654
+ val adapter =
2655
+ bluetoothAdapter
2656
+ ?: run {
2657
+ Bridge.log("G2: BluetoothAdapter not available")
2658
+ return false
2659
+ }
2660
+
2661
+ if (!adapter.isEnabled) {
2662
+ Bridge.log("G2: Bluetooth not enabled")
2663
+ return false
2664
+ }
2665
+
2666
+ isDisconnecting = false
2667
+
2668
+ // Try address-based reconnection first
2669
+ if (connectByAddress()) {
2670
+ return true
2671
+ }
2672
+
2673
+ val scanner =
2674
+ adapter.bluetoothLeScanner
2675
+ ?: run {
2676
+ Bridge.log("G2: BluetoothLeScanner not available")
2677
+ return false
2678
+ }
2679
+
2680
+ val settings =
2681
+ ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
2682
+
2683
+ val callback =
2684
+ object : ScanCallback() {
2685
+ override fun onScanResult(callbackType: Int, result: ScanResult?) {
2686
+ result ?: return
2687
+ val device = result.device ?: return
2688
+ val name = device.name ?: return
2689
+
2690
+ if (!name.contains("G2")) return
2691
+
2692
+ mainHandler.post {
2693
+ // Extract serial number from manufacturer data (like iOS)
2694
+ val serialNumber = extractSNFromScanRecord(result)
2695
+ if (serialNumber == null) {
2696
+ Bridge.log("G2: Discovered: $name but no SN in mfg data")
2697
+ return@post
2698
+ }
2699
+
2700
+ val mfgFirst = result.scanRecord?.manufacturerSpecificData?.valueAt(0)
2701
+ val mfgHex =
2702
+ mfgFirst?.joinToString(" ") { String.format("%02X", it) }
2703
+ ?: "none"
2704
+ Bridge.log(
2705
+ "G2: Discovered: $name (SN: $serialNumber) mfgData[${mfgFirst?.size ?: 0}]: $mfgHex"
2706
+ )
2707
+ deviceNameToSerialNumber[name] = serialNumber
2708
+
2709
+ // Save MAC per side; ring's advStart needs the left lens MAC.
2710
+ val mac = extractMacFromScanRecord(result)
2711
+ if (mac != null) {
2712
+ if (name.contains("_L_")) {
2713
+ DeviceStore.apply("glasses", "leftMacAddress", mac)
2714
+ DeviceStore.apply("glasses", "btMacAddress", mac)
2715
+ } else if (name.contains("_R_")) {
2716
+ DeviceStore.apply("glasses", "rightMacAddress", mac)
2717
+ }
2718
+ }
2719
+ // Stop scanning once we have both
2720
+ if (leftGatt != null && rightGatt != null) {
2721
+ stopScan()
2722
+ Bridge.log("G2: Stopped scan after discovering both devices")
2723
+ return@post
2724
+ }
2725
+
2726
+ // Always emit discovered device to frontend
2727
+ emitDiscoveredDevice(serialNumber)
2728
+
2729
+ // If scan-only mode, don't auto-connect
2730
+ if (DEVICE_SEARCH_ID == "NOT_SET") return@post
2731
+
2732
+ // Only connect to devices matching our search ID
2733
+ if (!serialNumber.contains(DEVICE_SEARCH_ID)) return@post
2734
+
2735
+ if (name.contains("_L_")) {
2736
+ if (leftGatt == null) {
2737
+ Bridge.log("G2: Connecting to LEFT: $name")
2738
+ leftGatt = device.connectGatt(context, false, leftGattCallback)
2739
+ }
2740
+ } else if (name.contains("_R_")) {
2741
+ if (rightGatt == null) {
2742
+ Bridge.log("G2: Connecting to RIGHT: $name")
2743
+ rightGatt =
2744
+ device.connectGatt(context, false, rightGattCallback)
2745
+ }
2746
+ }
2747
+
2748
+ // Stop scanning once we have both
2749
+ if (leftGatt != null && rightGatt != null) {
2750
+ stopScan()
2751
+ cancelPairingTimeout()
2752
+ Bridge.log("G2: Stopped scan after discovering both devices2")
2753
+ }
2754
+ }
2755
+ }
2756
+
2757
+ override fun onScanFailed(errorCode: Int) {
2758
+ Bridge.log("G2: Scan failed with error code: $errorCode")
2759
+ }
2760
+ }
2761
+
2762
+ scanCallback = callback
2763
+ try {
2764
+ scanner.startScan(null, settings, callback)
2765
+ } catch (e: SecurityException) {
2766
+ // Auto-reconnect paths may fire before BLUETOOTH_SCAN is granted on Android 12+
2767
+ Bridge.log("G2: startScan SecurityException — bluetooth permission missing: ${e.message}")
2768
+ scanCallback = null
2769
+ return false
2770
+ } catch (e: Exception) {
2771
+ Bridge.log("G2: startScan failed: ${e.message}")
2772
+ scanCallback = null
2773
+ return false
2774
+ }
2775
+ return true
2776
+ }
2777
+
2778
+ override fun stopScan() {
2779
+ scanCallback?.let { cb -> bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) }
2780
+ scanCallback = null
2781
+ }
2782
+
2783
+ private fun connectByAddress(): Boolean {
2784
+ if (DEVICE_SEARCH_ID == "NOT_SET" || DEVICE_SEARCH_ID.isEmpty()) {
2785
+ Bridge.log("G2: No DEVICE_SEARCH_ID set, skipping connect by address")
2786
+ return false
2787
+ }
2788
+
2789
+ val leftAddr = leftGlassAddress ?: return false
2790
+ val rightAddr = rightGlassAddress ?: return false
2791
+
2792
+ val adapter = bluetoothAdapter ?: return false
2793
+
2794
+ try {
2795
+ val leftDevice = adapter.getRemoteDevice(leftAddr)
2796
+ val rightDevice = adapter.getRemoteDevice(rightAddr)
2797
+
2798
+ Bridge.log(
2799
+ "G2: connectByAddress - left: ${leftDevice.name ?: leftAddr}, right: ${rightDevice.name ?: rightAddr}"
2800
+ )
2801
+
2802
+ leftGatt = leftDevice.connectGatt(context, false, leftGattCallback)
2803
+ rightGatt = rightDevice.connectGatt(context, false, rightGattCallback)
2804
+ return true
2805
+ } catch (e: Exception) {
2806
+ Bridge.log("G2: connectByAddress failed: ${e.message}")
2807
+ return false
2808
+ }
2809
+ }
2810
+
2811
+ /**
2812
+ * Extract serial number from BLE scan record manufacturer data. The SN is embedded in the
2813
+ * manufacturer-specific data payload. iOS: skip 2 bytes ("ER" prefix), read 14 bytes of ASCII
2814
+ * SN. Android: same approach on the manufacturer-specific data bytes.
2815
+ */
2816
+ private fun extractSNFromScanRecord(result: ScanResult): String? {
2817
+ val scanRecord = result.scanRecord ?: return null
2818
+
2819
+ // Get manufacturer-specific data
2820
+ // Android strips the 2-byte company ID (0x4552 = "ER"), so the SN starts at offset 0.
2821
+ // iOS keeps the "ER" prefix so it skips 2 bytes — we don't need to skip on Android.
2822
+ val mfgData = scanRecord.manufacturerSpecificData
2823
+ if (mfgData == null || mfgData.size() == 0) return null
2824
+
2825
+ val data = mfgData.valueAt(0) ?: return null
2826
+ if (data.size < 14) return null
2827
+
2828
+ // Read 14 bytes of ASCII SN starting at offset 0
2829
+ val snBytes = data.copyOfRange(0, minOf(14, data.size))
2830
+ val sn =
2831
+ String(snBytes, Charsets.US_ASCII)
2832
+ .replace(Regex("[\\x00-\\x1F\\x7F]"), "") // Strip control chars
2833
+ return if (sn.isNotEmpty()) sn else null
2834
+ }
2835
+
2836
+ /**
2837
+ * Extract the BLE MAC from the G2 scan record manufacturer data. Layout (after Android strips
2838
+ * the 2-byte company ID): SN(14) + MAC(6, little-endian) + flag(1) Returns "AA:BB:CC:DD:EE:FF"
2839
+ * (big-endian, colon-separated).
2840
+ */
2841
+ private fun extractMacFromScanRecord(result: ScanResult): String? {
2842
+ val scanRecord = result.scanRecord ?: return null
2843
+ val mfgData = scanRecord.manufacturerSpecificData
2844
+ if (mfgData == null || mfgData.size() == 0) return null
2845
+ val data = mfgData.valueAt(0) ?: return null
2846
+ if (data.size < 20) return null
2847
+ val macLE = data.copyOfRange(14, 20)
2848
+ return macLE.reversed().joinToString(":") { String.format("%02X", it.toInt() and 0xFF) }
2849
+ }
2850
+
2851
+ private fun emitDiscoveredDevice(serialNumber: String) {
2852
+ Bridge.sendDiscoveredDevice(DeviceTypes.G2, serialNumber)
2853
+ }
2854
+
2855
+ private fun extractIdNumber(name: String): Int? {
2856
+ val pattern = Pattern.compile("G2_(\\d+)_")
2857
+ val matcher = pattern.matcher(name)
2858
+ if (matcher.find()) {
2859
+ return matcher.group(1)?.toIntOrNull()
2860
+ }
2861
+ return null
2862
+ }
2863
+
2864
+ // ---------- GATT Callbacks ----------
2865
+
2866
+ private val leftGattCallback = createGattCallback("LEFT")
2867
+ private val rightGattCallback = createGattCallback("RIGHT")
2868
+
2869
+ @Suppress("deprecation")
2870
+ private fun createGattCallback(side: String): BluetoothGattCallback {
2871
+ return object : BluetoothGattCallback() {
2872
+ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
2873
+ mainHandler.post {
2874
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
2875
+ Bridge.log("G2: Connected to $side: ${gatt.device?.name ?: "unknown"}")
2876
+
2877
+ // Save address for reconnection
2878
+ val address = gatt.device?.address
2879
+ if (side == "LEFT") {
2880
+ leftGlassAddress = address
2881
+ } else {
2882
+ rightGlassAddress = address
2883
+ }
2884
+
2885
+ // Request a larger MTU so 200-byte audio notifications aren't fragmented.
2886
+ // Default ATT MTU is 23 → max payload 20 bytes, which would chop each audio
2887
+ // chunk into 10+ pieces. We ask for 247 (max for BLE 4.2+ data length ext).
2888
+ // discoverServices is deferred to onMtuChanged so the larger MTU is in
2889
+ // effect for the rest of the setup.
2890
+ val mtuRequested =
2891
+ try {
2892
+ gatt.requestMtu(247)
2893
+ } catch (e: SecurityException) {
2894
+ Bridge.log(
2895
+ "G2: requestMtu SecurityException on $side: ${e.message}"
2896
+ )
2897
+ false
2898
+ }
2899
+ if (!mtuRequested) {
2900
+ Bridge.log(
2901
+ "G2: requestMtu returned false on $side, proceeding without MTU bump"
2902
+ )
2903
+ gatt.discoverServices()
2904
+ }
2905
+
2906
+ // Ask for high connection priority so the link can sustain 16 kHz / 10 ms
2907
+ // audio without dropped notifications. Caller is responsible for dropping
2908
+ // back to BALANCED later if power becomes a concern.
2909
+ try {
2910
+ gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
2911
+ } catch (e: SecurityException) {
2912
+ Bridge.log(
2913
+ "G2: requestConnectionPriority SecurityException on $side: ${e.message}"
2914
+ )
2915
+ }
2916
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
2917
+ Bridge.log("G2: Disconnected $side")
2918
+
2919
+ if (isDisconnecting) return@post
2920
+
2921
+ // Clear both sides to force re-discovery
2922
+ leftGatt?.close()
2923
+ rightGatt?.close()
2924
+ leftGatt = null
2925
+ rightGatt = null
2926
+ leftInitialized = false
2927
+ rightInitialized = false
2928
+ leftWriteChar = null
2929
+ rightWriteChar = null
2930
+ leftNotifyChar = null
2931
+ rightNotifyChar = null
2932
+ leftAudioChar = null
2933
+ rightAudioChar = null
2934
+ authStarted = false
2935
+ leftAuthenticated = false
2936
+ rightAuthenticated = false
2937
+
2938
+ startupPageCreated = false
2939
+ pageCreated = false
2940
+ pageHasTextContainer = false
2941
+ DeviceStore.apply("glasses", "connected", false)
2942
+ DeviceStore.apply("glasses", "fullyBooted", false)
2943
+
2944
+ startReconnectionTimer()
2945
+ }
2946
+ }
2947
+ }
2948
+
2949
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
2950
+ Bridge.log("G2: onMtuChanged $side mtu=$mtu status=$status")
2951
+ mainHandler.post {
2952
+ // discoverServices was deferred until MTU negotiation finishes (success or
2953
+ // not).
2954
+ try {
2955
+ gatt.discoverServices()
2956
+ } catch (e: SecurityException) {
2957
+ Bridge.log("G2: discoverServices SecurityException on $side: ${e.message}")
2958
+ }
2959
+ }
2960
+ }
2961
+
2962
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
2963
+ if (status != BluetoothGatt.GATT_SUCCESS) return
2964
+
2965
+ mainHandler.post {
2966
+ val services = gatt.services ?: return@post
2967
+
2968
+ for (service in services) {
2969
+ for (char in service.characteristics) {
2970
+ val uuid = char.uuid
2971
+ val props = char.properties
2972
+
2973
+ var propStr = mutableListOf<String>()
2974
+ if (props and BluetoothGattCharacteristic.PROPERTY_READ != 0)
2975
+ propStr.add("read")
2976
+ if (props and BluetoothGattCharacteristic.PROPERTY_WRITE != 0)
2977
+ propStr.add("write")
2978
+ if (props and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE !=
2979
+ 0
2980
+ )
2981
+ propStr.add("writeNoResp")
2982
+ if (props and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0)
2983
+ propStr.add("notify")
2984
+ if (props and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0)
2985
+ propStr.add("indicate")
2986
+ Bridge.log("G2: $side char $uuid props=[${propStr.joinToString(",")}]")
2987
+
2988
+ when (uuid) {
2989
+ G2BLE.CHAR_WRITE -> {
2990
+ Bridge.log("G2: Found WRITE char on $side")
2991
+ if (side == "LEFT") leftWriteChar = char
2992
+ else rightWriteChar = char
2993
+ }
2994
+ G2BLE.CHAR_NOTIFY -> {
2995
+ Bridge.log("G2: Found NOTIFY char on $side")
2996
+ if (side == "LEFT") leftNotifyChar = char
2997
+ else rightNotifyChar = char
2998
+ enqueueGattOp { enableNotifications(gatt, char) }
2999
+ }
3000
+ G2BLE.AUDIO_NOTIFY -> {
3001
+ Bridge.log("G2: Found AUDIO char on $side")
3002
+ if (side == "LEFT") leftAudioChar = char
3003
+ else rightAudioChar = char
3004
+ enqueueGattOp { enableNotifications(gatt, char) }
3005
+ }
3006
+ }
3007
+ }
3008
+ }
3009
+
3010
+ // Check if this side is fully initialized
3011
+ if (side == "LEFT" && leftWriteChar != null) {
3012
+ leftInitialized = true
3013
+ Bridge.log("G2: LEFT initialized")
3014
+ } else if (side == "RIGHT" && rightWriteChar != null && rightNotifyChar != null
3015
+ ) {
3016
+ rightInitialized = true
3017
+ Bridge.log("G2: RIGHT initialized")
3018
+ }
3019
+
3020
+ // Both sides ready -> run auth (once)
3021
+ if (leftInitialized && rightInitialized && !authStarted) {
3022
+ // stop scanning
3023
+ stopScan()
3024
+ authStarted = true
3025
+ Bridge.log("G2: Both sides initialized, starting auth sequence")
3026
+ runAuthSequence()
3027
+ }
3028
+ }
3029
+ }
3030
+
3031
+ @Deprecated("Deprecated in API level 33")
3032
+ override fun onCharacteristicChanged(
3033
+ gatt: BluetoothGatt,
3034
+ characteristic: BluetoothGattCharacteristic
3035
+ ) {
3036
+ val data = characteristic.value ?: return
3037
+
3038
+ val sourceKey = if (side == "LEFT") "L" else "R"
3039
+ when (characteristic.uuid) {
3040
+ G2BLE.AUDIO_NOTIFY -> handleAudioData(data, sourceKey)
3041
+ G2BLE.CHAR_NOTIFY -> mainHandler.post { handleNotifyData(data, sourceKey) }
3042
+ }
3043
+ }
3044
+
3045
+ override fun onDescriptorWrite(
3046
+ gatt: BluetoothGatt,
3047
+ descriptor: BluetoothGattDescriptor,
3048
+ status: Int
3049
+ ) {
3050
+ mainHandler.post {
3051
+ // Process next queued GATT operation
3052
+ gattOpInProgress = false
3053
+ processGattOpQueue()
3054
+ }
3055
+ }
3056
+ }
3057
+ }
3058
+
3059
+ // GATT operation queue (Android only allows one outstanding GATT op at a time)
3060
+ private fun enqueueGattOp(op: () -> Unit) {
3061
+ gattOpQueue.add(op)
3062
+ if (!gattOpInProgress) {
3063
+ processGattOpQueue()
3064
+ }
3065
+ }
3066
+
3067
+ private fun processGattOpQueue() {
3068
+ if (gattOpQueue.isEmpty()) return
3069
+ gattOpInProgress = true
3070
+ val op = gattOpQueue.removeAt(0)
3071
+ op()
3072
+ }
3073
+
3074
+ @Suppress("deprecation")
3075
+ private fun enableNotifications(
3076
+ gatt: BluetoothGatt,
3077
+ characteristic: BluetoothGattCharacteristic
3078
+ ) {
3079
+ gatt.setCharacteristicNotification(characteristic, true)
3080
+ val descriptor = characteristic.getDescriptor(G2BLE.CLIENT_CHARACTERISTIC_CONFIG)
3081
+ if (descriptor != null) {
3082
+ descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
3083
+ gatt.writeDescriptor(descriptor)
3084
+ } else {
3085
+ // No descriptor, move to next op
3086
+ gattOpInProgress = false
3087
+ processGattOpQueue()
3088
+ }
3089
+ }
3090
+
3091
+ // ---------- Incoming Data Handling ----------
3092
+
3093
+ private fun handleNotifyData(data: ByteArray, sourceKey: String) {
3094
+ val result = receiveManager.handlePacket(data, sourceKey) ?: return
3095
+
3096
+ val serviceId = result.first
3097
+ val payload = result.second
3098
+
3099
+ when (serviceId) {
3100
+ ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload)
3101
+ ServiceID.DEVICE_SETTINGS.value -> handleDevSettingsResponse(payload, sourceKey)
3102
+ ServiceID.G2_SETTING.value -> handleG2SettingResponse(payload)
3103
+ ServiceID.MENU.value -> handleMenuResponse(payload)
3104
+ ServiceID.DASHBOARD.value -> handleDashboardResponse(payload)
3105
+ ServiceID.GESTURE_CTRL.value -> handleGestureCtrl(payload)
3106
+ ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
3107
+ else -> {
3108
+ Bridge.log(
3109
+ "G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
3110
+ payload.take(32).joinToString("") { String.format("%02X", it) }
3111
+ }"
3112
+ )
3113
+ }
3114
+ }
3115
+ }
3116
+
3117
+ private fun handleEvenHubResponse(payload: ByteArray) {
3118
+ val reader = ProtobufReader(payload)
3119
+ val fields = reader.parseFields()
3120
+
3121
+ val cmdValue =
3122
+ fields[1] as? Int
3123
+ ?: run {
3124
+ Bridge.log(
3125
+ "G2: EvenHub response - no cmd field, ${payload.size} bytes: ${
3126
+ payload.joinToString("") { String.format("%02X", it) }
3127
+ }"
3128
+ )
3129
+ return
3130
+ }
3131
+
3132
+ if (cmdValue == EvenHubResponseCmd.OS_NOTIFY_EVENT_TO_APP.value) {
3133
+ // Touch/gesture event from glasses
3134
+ val devEventData = fields[13] as? ByteArray ?: return
3135
+ val timestamp = System.currentTimeMillis()
3136
+ val last = lastClickTimestamp
3137
+ if (last != null && timestamp - last < 250) {
3138
+ return
3139
+ }
3140
+ lastClickTimestamp = timestamp
3141
+ handleTouchEvent(devEventData)
3142
+ } else if (cmdValue == 17) {
3143
+ // Miniapp selection from glasses dashboard menu (cmdId=17)
3144
+ // Dedup: L and R peripherals both deliver this event, so debounce or
3145
+ // MantleManager toggles start→stop in quick succession.
3146
+ val timestamp = System.currentTimeMillis()
3147
+ val lastMenu = lastMenuSelectTimestamp
3148
+ if (lastMenu != null && timestamp - lastMenu < 500) {
3149
+ return
3150
+ }
3151
+ lastMenuSelectTimestamp = timestamp
3152
+ // field 20 contains sub-message with field 1 = itemAppId
3153
+ val selectData = fields[20] as? ByteArray ?: return
3154
+ val selectReader = ProtobufReader(selectData)
3155
+ val selectFields = selectReader.parseFields()
3156
+ val appId = selectFields[1] as? Int ?: return
3157
+ // Resolve appId → packageName using our stored mapping
3158
+ val packageName = menuAppIdToPackageName[appId]
3159
+ if (packageName != null) {
3160
+ Bridge.log("G2: Menu miniapp selected — $packageName")
3161
+ Bridge.sendMiniappSelected(packageName)
3162
+ mainHandler.postDelayed({ clearDisplay() }, 500)
3163
+ } else {
3164
+ Bridge.log("G2: Menu selection ignored — placeholder or unknown appId=$appId")
3165
+ }
3166
+ } else {
3167
+ // Parse error codes from responses
3168
+ // field 4 = StartupResCmd, field 6 = ImgResCmd, field 8 = RebuildResCmd, field 10 =
3169
+ // TextResCmd
3170
+ for (resField in listOf(4, 6, 8, 10)) {
3171
+ val resData = fields[resField] as? ByteArray ?: continue
3172
+ val resReader = ProtobufReader(resData)
3173
+ val resFields = resReader.parseFields()
3174
+ (resFields[1] as? Int)?.let { errorCode ->
3175
+ // 0=page_success, 4=img_success, 5=img_failed, 6=rebuild_success,
3176
+ // 7=rebuild_failed, 8=text_success, 9=text_failed
3177
+ if (errorCode == 9) {
3178
+ Bridge.log(
3179
+ "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
3180
+ )
3181
+ startupPageCreated = false
3182
+ pageCreated = false
3183
+ pageHasTextContainer = false
3184
+ currentTextContent = ""
3185
+ }
3186
+ }
3187
+ (resFields[8] as? Int)?.let { errorCode ->
3188
+ // ImgResCmd has ErrorCode in field 8
3189
+ Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
3190
+ }
3191
+ }
3192
+
3193
+ // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state
3194
+ if (cmdValue == 9 || cmdValue == 10) {
3195
+ Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3196
+ startupPageCreated = false
3197
+ pageCreated = false
3198
+ pageHasTextContainer = false
3199
+ currentTextContent = ""
3200
+ }
3201
+ }
3202
+ }
3203
+
3204
+ private fun setFullyConnected() {
3205
+ val isFullyConnected = DeviceStore.get("glasses", "connected") as? Boolean ?: false
3206
+ val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
3207
+ if (!isFullyConnected) {
3208
+ DeviceStore.apply("glasses", "connected", true)
3209
+ }
3210
+ if (!isFullyBooted) {
3211
+ DeviceStore.apply("glasses", "fullyBooted", true)
3212
+ }
3213
+ }
3214
+
3215
+ private fun setControllerFullyConnected() {
3216
+ val isControllerConnected =
3217
+ DeviceStore.get("glasses", "controllerConnected") as? Boolean ?: false
3218
+ val isControllerFullyBooted =
3219
+ DeviceStore.get("glasses", "controllerFullyBooted") as? Boolean ?: false
3220
+ if (!isControllerConnected) {
3221
+ DeviceStore.apply("glasses", "controllerConnected", true)
3222
+ }
3223
+ if (!isControllerFullyBooted) {
3224
+ DeviceStore.apply("glasses", "controllerFullyBooted", true)
3225
+ }
3226
+ }
3227
+
3228
+ private fun handleTouchEvent(devEventData: ByteArray) {
3229
+ // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
3230
+ val reader = ProtobufReader(devEventData)
3231
+ val fields = reader.parseFields()
3232
+
3233
+ val timestamp = System.currentTimeMillis()
3234
+
3235
+ // if we are receiving touch events we are fully booted:
3236
+ setFullyConnected()
3237
+
3238
+ // SysEvent (field 3) - system-level gestures
3239
+ (fields[3] as? ByteArray)?.let { sysData ->
3240
+ val sysReader = ProtobufReader(sysData)
3241
+ val sysFields = sysReader.parseFields()
3242
+
3243
+ val normalType = sysFields[1] as? Int
3244
+ val eventType: OsEventType? =
3245
+ if (normalType != null) OsEventType.fromInt(normalType) else OsEventType.CLICK
3246
+ val eventSource: Int? = sysFields[2] as? Int
3247
+
3248
+ if (eventType == null) {
3249
+ Bridge.log("G2: unknown event type: $sysFields")
3250
+ return@let
3251
+ }
3252
+
3253
+ val gestureName = mapEventTypeToGesture(eventType)
3254
+ if (gestureName == null) {
3255
+ Bridge.log("G2: no gesture mapping for $eventType $sysFields")
3256
+ return@let
3257
+ }
3258
+
3259
+ Bridge.sendTouchEvent(DeviceTypes.G2, gestureName, timestamp, eventSource)
3260
+ Bridge.log("G2: SysEvent → $eventType $eventSource")
3261
+
3262
+ if (eventSource == 2) {
3263
+ // controller must be connected and fully booted:
3264
+ setControllerFullyConnected()
3265
+ }
3266
+
3267
+ if (eventType == OsEventType.DOUBLE_CLICK) {
3268
+ // trigger dashboard:
3269
+ val isHeadUp = DeviceStore.get("glasses", "headUp") as? Boolean ?: false
3270
+ // toggle head up:
3271
+ DeviceStore.apply("glasses", "headUp", !isHeadUp)
3272
+ // if (isHeadUp) {
3273
+ // // clear the display after a delay:
3274
+ // mainHandler.postDelayed({ clearDisplay() }, 500)
3275
+ // }
3276
+ }
3277
+
3278
+ // System exit: glasses killed our EvenHub page (user opened menu or another app)
3279
+ // Reset page state and re-create the page to reclaim EvenHub focus
3280
+ if (eventType == OsEventType.SYSTEM_EXIT || eventType == OsEventType.ABNORMAL_EXIT) {
3281
+ startupPageCreated = false
3282
+ pageCreated = false
3283
+ pageHasTextContainer = false
3284
+ currentTextContent = ""
3285
+ currentBitmapBase64 = ""
3286
+ // Firmware kills the mic on system exit; re-arm it if it should be on
3287
+ DeviceStore.apply("glasses", "micEnabled", false)
3288
+ DeviceManager.getInstance().updateMicState()
3289
+ }
3290
+ return
3291
+ }
3292
+
3293
+ // TextEvent (field 2) - tap on text container
3294
+ (fields[2] as? ByteArray)?.let { textData ->
3295
+ val textReader = ProtobufReader(textData)
3296
+ val textFields = textReader.parseFields()
3297
+ val eventTypeRaw = textFields[3] as? Int ?: return@let
3298
+ val eventType = OsEventType.fromInt(eventTypeRaw) ?: return@let
3299
+ val gestureName = mapEventTypeToGesture(eventType)
3300
+ // log raw event data:
3301
+ // Bridge.log("G2: TextEvent raw data: ${textData.joinToString("") {
3302
+ // String.format("%02X", it) }}")
3303
+ // Bridge.log("G2: TextEvent fields: $textFields")
3304
+
3305
+ if (gestureName == null) {
3306
+ Bridge.log("G2: no gesture mapping for $eventType $textFields")
3307
+ return@let
3308
+ }
3309
+ Bridge.sendTouchEvent(DeviceTypes.G2, gestureName, timestamp)
3310
+ Bridge.log("G2: TextEvent → $gestureName")
3311
+ return
3312
+ }
3313
+
3314
+ // ListEvent (field 1) - interaction with list container (not currently handled)
3315
+ }
3316
+
3317
+ private fun mapEventTypeToGesture(eventType: OsEventType): String? {
3318
+ return when (eventType) {
3319
+ OsEventType.CLICK -> "single_tap"
3320
+ OsEventType.DOUBLE_CLICK -> "double_tap"
3321
+ OsEventType.SCROLL_TOP -> "swipe_up"
3322
+ OsEventType.SCROLL_BOTTOM -> "swipe_down"
3323
+ OsEventType.FOREGROUND_ENTER -> "foreground_enter"
3324
+ OsEventType.FOREGROUND_EXIT -> "foreground_exit"
3325
+ OsEventType.SYSTEM_EXIT -> "system_exit"
3326
+ OsEventType.ABNORMAL_EXIT -> null
3327
+ }
3328
+ }
3329
+
3330
+ private fun handleDevSettingsResponse(payload: ByteArray, sourceKey: String) {
3331
+ val reader = ProtobufReader(payload)
3332
+ val fields = reader.parseFields()
3333
+ val cmdValue = fields[1] as? Int ?: -1
3334
+
3335
+ // Ignore heartbeat acks
3336
+ if (cmdValue == DevCfgCommandId.BASE_CONN_HEART_BEAT.value) return
3337
+
3338
+ Bridge.log(
3339
+ "G2: DevSettings response: ${payload.take(32).joinToString(":") { String.format("%02X", it) }}"
3340
+ )
3341
+
3342
+ if (cmdValue == DevCfgCommandId.AUTHENTICATION.value) {
3343
+ // DevCfgDataPackage: field 2 = magicRandom, field 3 = AuthMgr { field 1 = secAuth }
3344
+ var secAuth: Boolean? = null
3345
+ (fields[3] as? ByteArray)?.let { authData ->
3346
+ val authReader = ProtobufReader(authData)
3347
+ val authFields = authReader.parseFields()
3348
+ (authFields[1] as? Int)?.let { secAuth = (it != 0) }
3349
+ }
3350
+ val secAuthStr = secAuth?.toString() ?: "?"
3351
+ Bridge.log("G2: Authentication response: $sourceKey secAuth=$secAuthStr")
3352
+ if (secAuth == true) {
3353
+ if (sourceKey == "L") {
3354
+ leftAuthenticated = true
3355
+ } else if (sourceKey == "R") {
3356
+ rightAuthenticated = true
3357
+ }
3358
+ if (leftAuthenticated && rightAuthenticated) {
3359
+ Bridge.log("G2: Both sides authenticated, setting fully booted and connected")
3360
+ setFullyConnected()
3361
+ }
3362
+ }
3363
+ }
3364
+
3365
+ // RING_CONNECT_INFO response (cmd 6)
3366
+ if (cmdValue == DevCfgCommandId.RING_CONNECT_INFO.value) {
3367
+ (fields[5] as? ByteArray)?.let { ringData ->
3368
+ val ringReader = ProtobufReader(ringData)
3369
+ val ringFields = ringReader.parseFields()
3370
+
3371
+ if ((ringFields[1] as? Int ?: 0) == 1) {
3372
+ Bridge.log("G2: Ring maybe connected?")
3373
+ DeviceStore.apply("glasses", "controllerFullyBooted", true)
3374
+ }
3375
+
3376
+ if ((ringFields[4] as? Int ?: 0) == 62) {
3377
+ Bridge.log("G2: Ring maybe reconnected?")
3378
+ DeviceStore.apply("glasses", "controllerFullyBooted", true)
3379
+ }
3380
+
3381
+ val connStatus = ringFields[4] as? Int ?: -1
3382
+ Bridge.log("G2: Ring connection status: connStatus?=$connStatus")
3383
+
3384
+ if (connStatus == 22) {
3385
+ Bridge.log("G2: Ring disconnected")
3386
+ DeviceStore.apply("glasses", "controllerFullyBooted", false)
3387
+ DeviceStore.apply("glasses", "controllerSearching", true)
3388
+ reconnectController()
3389
+ }
3390
+
3391
+ if (connStatus == 8) {
3392
+ Bridge.log("G2: Ring maybe disconnected?")
3393
+ // DeviceStore.apply("glasses", "controllerFullyBooted", false)
3394
+ // DeviceStore.apply("glasses", "controllerSearching", true)
3395
+ // reconnectController()
3396
+ }
3397
+ }
3398
+ }
3399
+ }
3400
+
3401
+ private fun handleMenuResponse(payload: ByteArray) {
3402
+ Bridge.log(
3403
+ "G2: menu response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
3404
+ )
3405
+ }
3406
+
3407
+ private fun handleDashboardResponse(payload: ByteArray) {
3408
+ Bridge.log(
3409
+ "G2: dashboard response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
3410
+ )
3411
+ val reader = ProtobufReader(payload)
3412
+ val fields = reader.parseFields()
3413
+ val cmd = fields[1] as? Int ?: -1
3414
+ val magicRandom = fields[2] as? Int ?: 0
3415
+
3416
+ // Parse field 6 (DashboardSendToApp) if present
3417
+ var packageId = 0
3418
+ (fields[6] as? ByteArray)?.let { f6 ->
3419
+ val subReader = ProtobufReader(f6)
3420
+ val sub = subReader.parseFields()
3421
+ packageId = sub[1] as? Int ?: 0
3422
+ }
3423
+
3424
+ // cmd=3 is APP_Respond — glasses sending us info, we should respond with cmd=4
3425
+ // (APP_RECEIVE)
3426
+ if (cmd == 3) {
3427
+ val appRespW = ProtobufWriter()
3428
+ appRespW.writeInt32Field(1, packageId) // packageId
3429
+ appRespW.writeInt32Field(2, 0) // flag = APP_RECEIVED_SUCCESS
3430
+
3431
+ val pkgW = ProtobufWriter()
3432
+ pkgW.writeInt32Field(1, 4) // commandId = APP_RECEIVE
3433
+ pkgW.writeInt32Field(2, magicRandom)
3434
+ pkgW.writeMessageField(5, appRespW.toByteArray()) // field5 = appRespond
3435
+ sendDashboardCommand(pkgW.toByteArray())
3436
+ }
3437
+ }
3438
+
3439
+ private fun handleEvenHubCtrlResponse(payload: ByteArray) {
3440
+ Bridge.log(
3441
+ "G2: evenHubCtrl response: ${payload.take(8).joinToString("") { String.format("%02X", it) }}"
3442
+ )
3443
+ }
3444
+
3445
+ private fun handleGestureCtrl(payload: ByteArray) {
3446
+ // Dashboard close detection: 08011A00 means dashboard closed
3447
+ if (payload.contentEquals(byteArrayOf(0x08, 0x01, 0x1A, 0x00))) {
3448
+ Bridge.log("G2: gesture_ctrl response: dashboard closed")
3449
+ // Re-send mic on / update mic state
3450
+ DeviceStore.apply("glasses", "micEnabled", false)
3451
+ DeviceManager.getInstance().updateMicState()
3452
+ // Reset the text container
3453
+ sendTextWall(" ")
3454
+ }
3455
+ }
3456
+
3457
+ private fun handleG2SettingResponse(payload: ByteArray) {
3458
+ val reader = ProtobufReader(payload)
3459
+ val fields = reader.parseFields()
3460
+
3461
+ val cmdValue = fields[1] as? Int ?: return
3462
+
3463
+ if (cmdValue == G2SettingCommandId.DEVICE_RECEIVE_REQUEST.value ||
3464
+ cmdValue == G2SettingCommandId.DEVICE_SEND_TO_APP.value
3465
+ ) {
3466
+ (fields[4] as? ByteArray)?.let { parseDeviceRequestResponse(it) }
3467
+ (fields[5] as? ByteArray)?.let { parseDeviceSendToApp(it) }
3468
+ }
3469
+ }
3470
+
3471
+ private fun parseDeviceRequestResponse(data: ByteArray) {
3472
+ val reader = ProtobufReader(data)
3473
+ val fields = reader.parseFields()
3474
+
3475
+ setFullyConnected()
3476
+
3477
+ // Battery
3478
+ (fields[12] as? Int)?.let { battery ->
3479
+ if (battery in 0..100) {
3480
+ Bridge.log("G2: Battery level: $battery%")
3481
+ batteryLevel_ = battery
3482
+ }
3483
+ }
3484
+
3485
+ // Charging status
3486
+ (fields[13] as? Int)?.let { charging ->
3487
+ isCharging = charging != 0
3488
+ Bridge.log("G2: Charging: $isCharging")
3489
+ if (_batteryLevel >= 0) {
3490
+ Bridge.sendBatteryStatus(_batteryLevel, isCharging)
3491
+ }
3492
+ }
3493
+
3494
+ // Software versions
3495
+ (fields[5] as? ByteArray)?.let { leftVer ->
3496
+ val leftVersion = String(leftVer, Charsets.UTF_8)
3497
+ Bridge.log("G2: Left firmware: $leftVersion")
3498
+ DeviceStore.apply("glasses", "leftFirmwareVersion", leftVersion)
3499
+ }
3500
+
3501
+ (fields[6] as? ByteArray)?.let { rightVer ->
3502
+ val rightVersion = String(rightVer, Charsets.UTF_8)
3503
+ Bridge.log("G2: Right firmware: $rightVersion")
3504
+ DeviceStore.apply("glasses", "rightFirmwareVersion", rightVersion)
3505
+ DeviceStore.apply("glasses", "firmwareVersion", rightVersion)
3506
+ }
3507
+ }
3508
+
3509
+ private fun parseDeviceSendToApp(data: ByteArray) {
3510
+ val reader = ProtobufReader(data)
3511
+ val fields = reader.parseFields()
3512
+ (fields[2] as? Int)?.let { silentMode -> Bridge.log("G2: Silent mode: ${silentMode != 0}") }
3513
+ }
3514
+
3515
+ // ---------- Audio Handling ----------
3516
+
3517
+ private var lastAudioFrame: ByteArray? = null
3518
+
3519
+ private fun handleAudioData(data: ByteArray, sourceKey: String) {
3520
+ // Diagnostic: if BLE notifications are arriving fragmented (MTU too small), data.size
3521
+ // will be consistently < 200. Expected: ~200-byte chunks (5 × 40-byte LC3 frames).
3522
+
3523
+ val usableLength = minOf(data.size, 200)
3524
+ if (usableLength < 40) return
3525
+
3526
+ val audioData = data.copyOfRange(0, usableLength)
3527
+ if (lastAudioFrame?.contentEquals(audioData) == true) {
3528
+ // Bridge.log("G2: audio dup from $sourceKey: ${data.take(10).joinToString("") { String.format("%02X", it) }}")
3529
+ return
3530
+ }
3531
+ lastAudioFrame = audioData
3532
+ Bridge.log("G2: audio data from $sourceKey: ${data.take(10).joinToString("") { String.format("%02X", it) }}")
3533
+ DeviceManager.getInstance().handleGlassesMicData(audioData, 40)
3534
+ }
3535
+
3536
+ // ---------- Reconnection ----------
3537
+
3538
+ private fun startReconnectionTimer() {
3539
+ reconnectionManager.start {
3540
+ val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
3541
+ if (isFullyBooted) {
3542
+ Bridge.log("G2: Already connected, stopping reconnection")
3543
+ return@start true
3544
+ }
3545
+
3546
+ Bridge.log("G2: Attempting reconnection...")
3547
+ startScan()
3548
+ return@start false
3549
+ }
3550
+ }
3551
+ }