neoagent 2.3.1-beta.2 → 2.3.1-beta.21

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 (264) hide show
  1. package/.env.example +39 -0
  2. package/README.md +2 -0
  3. package/docs/capabilities.md +2 -2
  4. package/docs/configuration.md +13 -5
  5. package/docs/integrations.md +4 -1
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +109 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  30. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  31. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  32. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  33. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  35. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  36. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  37. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  38. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  39. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  43. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  44. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  45. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  46. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  48. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  49. package/flutter_app/android/build.gradle.kts +24 -0
  50. package/flutter_app/android/ci-release.keystore +0 -0
  51. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  52. package/flutter_app/android/gradle.properties +3 -0
  53. package/flutter_app/android/key.properties +4 -0
  54. package/flutter_app/android/settings.gradle.kts +26 -0
  55. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  57. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  58. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  59. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  60. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  62. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  63. package/flutter_app/lib/features/location/location_service.dart +119 -0
  64. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  65. package/flutter_app/lib/main.dart +23057 -0
  66. package/flutter_app/lib/main_app_shell.dart +1682 -0
  67. package/flutter_app/lib/main_integrations.dart +931 -0
  68. package/flutter_app/lib/main_launcher.dart +959 -0
  69. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  70. package/flutter_app/lib/main_models.dart +3473 -0
  71. package/flutter_app/lib/main_shared.dart +2861 -0
  72. package/flutter_app/lib/main_theme.dart +204 -0
  73. package/flutter_app/lib/main_voice_assistant.dart +831 -0
  74. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  75. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  76. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  77. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  78. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  79. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  80. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  81. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  82. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  83. package/flutter_app/lib/src/backend_client.dart +1833 -0
  84. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  85. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  86. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  87. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  88. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  89. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  90. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  91. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  92. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  93. package/flutter_app/lib/src/health_bridge.dart +136 -0
  94. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  95. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  96. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  97. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  98. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  99. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  100. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  101. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  102. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  103. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  104. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  105. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  106. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  107. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  108. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  109. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  110. package/flutter_app/lib/src/theme/palette.dart +81 -0
  111. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  112. package/flutter_app/linux/CMakeLists.txt +128 -0
  113. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  114. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  115. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  116. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  117. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  118. package/flutter_app/linux/runner/main.cc +6 -0
  119. package/flutter_app/linux/runner/my_application.cc +144 -0
  120. package/flutter_app/linux/runner/my_application.h +18 -0
  121. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  122. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  123. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  124. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  125. package/flutter_app/macos/Podfile +42 -0
  126. package/flutter_app/macos/Podfile.lock +87 -0
  127. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  128. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  129. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  130. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  131. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  132. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  133. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  134. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  135. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  136. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  137. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  138. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  139. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  140. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  141. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  142. package/flutter_app/macos/Runner/Info.plist +36 -0
  143. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  144. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  145. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  146. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  147. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  148. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  149. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  150. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  151. package/flutter_app/patch_strings.py +12 -0
  152. package/flutter_app/pubspec.lock +1088 -0
  153. package/flutter_app/pubspec.yaml +53 -0
  154. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  155. package/flutter_app/test/recording_payloads_test.dart +53 -0
  156. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  157. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  158. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  159. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  160. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  161. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  162. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  163. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  164. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  165. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  166. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  167. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  168. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  169. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  170. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  171. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  172. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  173. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  174. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  175. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  176. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  177. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  178. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  179. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  180. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  181. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  182. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  183. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  184. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  185. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  186. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  187. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  188. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  189. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  190. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  191. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  192. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  193. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  194. package/flutter_app/web/favicon.png +0 -0
  195. package/flutter_app/web/favicon.svg +12 -0
  196. package/flutter_app/web/icons/Icon-192.png +0 -0
  197. package/flutter_app/web/icons/Icon-512.png +0 -0
  198. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  199. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  200. package/flutter_app/web/index.html +39 -0
  201. package/flutter_app/web/manifest.json +35 -0
  202. package/flutter_app/windows/CMakeLists.txt +108 -0
  203. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  204. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  205. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  206. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  207. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  208. package/flutter_app/windows/runner/Runner.rc +121 -0
  209. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  210. package/flutter_app/windows/runner/flutter_window.h +37 -0
  211. package/flutter_app/windows/runner/main.cpp +53 -0
  212. package/flutter_app/windows/runner/resource.h +16 -0
  213. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  214. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  215. package/flutter_app/windows/runner/utils.cpp +65 -0
  216. package/flutter_app/windows/runner/utils.h +19 -0
  217. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  218. package/flutter_app/windows/runner/win32_window.h +102 -0
  219. package/lib/manager.js +231 -7
  220. package/package.json +3 -1
  221. package/server/db/database.js +68 -0
  222. package/server/http/middleware.js +50 -0
  223. package/server/http/routes.js +3 -1
  224. package/server/index.js +1 -0
  225. package/server/public/.last_build_id +1 -1
  226. package/server/public/assets/NOTICES +61 -0
  227. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  228. package/server/public/flutter_bootstrap.js +1 -1
  229. package/server/public/main.dart.js +65262 -64422
  230. package/server/routes/integrations.js +86 -0
  231. package/server/routes/memory.js +11 -2
  232. package/server/routes/screenHistory.js +46 -0
  233. package/server/routes/triggers.js +81 -0
  234. package/server/services/ai/models.js +30 -0
  235. package/server/services/ai/providers/githubCopilot.js +97 -0
  236. package/server/services/ai/providers/openai.js +2 -1
  237. package/server/services/ai/providers/openaiCodex.js +31 -0
  238. package/server/services/ai/settings.js +20 -0
  239. package/server/services/ai/systemPrompt.js +1 -1
  240. package/server/services/ai/tools.js +35 -6
  241. package/server/services/browser/controller.js +47 -3
  242. package/server/services/desktop/screenRecorder.js +172 -0
  243. package/server/services/integrations/env.js +5 -0
  244. package/server/services/integrations/github/common.js +106 -0
  245. package/server/services/integrations/github/provider.js +499 -0
  246. package/server/services/integrations/github/repos.js +1124 -0
  247. package/server/services/integrations/home_assistant/provider.js +306 -26
  248. package/server/services/integrations/manager.js +63 -7
  249. package/server/services/integrations/oauth_provider.js +13 -6
  250. package/server/services/integrations/provider_config_store.js +76 -0
  251. package/server/services/integrations/registry.js +4 -0
  252. package/server/services/integrations/trello/provider.js +744 -0
  253. package/server/services/integrations/whatsapp/provider.js +6 -2
  254. package/server/services/manager.js +22 -0
  255. package/server/services/memory/manager.js +39 -2
  256. package/server/services/skills/base_catalog.js +33 -0
  257. package/server/services/tasks/adapters/index.js +1 -0
  258. package/server/services/tasks/adapters/manual.js +12 -0
  259. package/server/services/tasks/runtime.js +1 -1
  260. package/server/services/voice/openaiClient.js +4 -1
  261. package/server/services/voice/providers.js +2 -1
  262. package/server/services/widgets/service.js +49 -4
  263. package/server/utils/local_secrets.js +56 -0
  264. package/server/utils/logger.js +37 -9
@@ -0,0 +1,1019 @@
1
+ import 'dart:async';
2
+ import 'dart:io';
3
+
4
+ import 'package:desktop_audio_capture/audio_capture.dart';
5
+ import 'package:flutter/foundation.dart';
6
+ import 'package:flutter/services.dart';
7
+ import 'package:http/http.dart' as http;
8
+
9
+ import 'diagnostics_logger.dart';
10
+ import 'recording_bridge.dart';
11
+
12
+ RecordingBridge createPlatformRecordingBridge() {
13
+ if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
14
+ return AndroidRecordingBridge();
15
+ }
16
+ if (!kIsWeb &&
17
+ (defaultTargetPlatform == TargetPlatform.macOS ||
18
+ defaultTargetPlatform == TargetPlatform.windows ||
19
+ defaultTargetPlatform == TargetPlatform.linux)) {
20
+ return DesktopRecordingBridge();
21
+ }
22
+ return UnsupportedIoRecordingBridge();
23
+ }
24
+
25
+ class UnsupportedIoRecordingBridge extends RecordingBridge {
26
+ RecordingRuntimeStatus _status = const RecordingRuntimeStatus(
27
+ supportsScreenAndMic: false,
28
+ supportsBackgroundMic: false,
29
+ platformLabel: 'Unsupported',
30
+ );
31
+
32
+ @override
33
+ RecordingRuntimeStatus get status => _status;
34
+
35
+ @override
36
+ Future<void> refreshStatus() async {}
37
+
38
+ @override
39
+ Future<void> startWebRecording({
40
+ required String baseUrl,
41
+ required String sessionId,
42
+ }) async {
43
+ throw const RecordingBridgeException(
44
+ 'Screen and microphone recording is available on web only.',
45
+ );
46
+ }
47
+
48
+ @override
49
+ Future<void> startWebMicrophoneRecording({
50
+ required String baseUrl,
51
+ required String sessionId,
52
+ }) async {
53
+ throw const RecordingBridgeException(
54
+ 'Microphone-only browser recording is available on web only.',
55
+ );
56
+ }
57
+
58
+ @override
59
+ Future<void> startBackgroundRecording({
60
+ required String baseUrl,
61
+ required String sessionCookie,
62
+ required String sessionId,
63
+ }) async {
64
+ throw const RecordingBridgeException(
65
+ 'Background microphone recording is not supported on this platform.',
66
+ );
67
+ }
68
+
69
+ @override
70
+ Future<void> startDesktopAudioRecording({
71
+ required String baseUrl,
72
+ required String sessionCookie,
73
+ required String sessionId,
74
+ bool autoShowToolbar = true,
75
+ }) async {
76
+ throw const RecordingBridgeException(
77
+ 'Desktop recording is not supported on this platform.',
78
+ );
79
+ }
80
+
81
+ @override
82
+ Future<void> pauseBackgroundRecording() async {
83
+ throw const RecordingBridgeException(
84
+ 'Recording is not supported on this platform.',
85
+ );
86
+ }
87
+
88
+ @override
89
+ Future<void> resumeBackgroundRecording() async {
90
+ throw const RecordingBridgeException(
91
+ 'Recording is not supported on this platform.',
92
+ );
93
+ }
94
+
95
+ @override
96
+ Future<void> pauseDesktopRecording() async {
97
+ throw const RecordingBridgeException(
98
+ 'Recording is not supported on this platform.',
99
+ );
100
+ }
101
+
102
+ @override
103
+ Future<void> resumeDesktopRecording() async {
104
+ throw const RecordingBridgeException(
105
+ 'Recording is not supported on this platform.',
106
+ );
107
+ }
108
+
109
+ @override
110
+ Future<void> showFloatingToolbar() async {}
111
+
112
+ @override
113
+ Future<void> hideFloatingToolbar() async {}
114
+
115
+ @override
116
+ Future<void> openMicrophoneSettings() async {
117
+ throw const RecordingBridgeException(
118
+ 'Microphone settings are not supported on this platform.',
119
+ );
120
+ }
121
+
122
+ @override
123
+ Future<void> openSystemAudioSettings() async {
124
+ throw const RecordingBridgeException(
125
+ 'System audio settings are not supported on this platform.',
126
+ );
127
+ }
128
+
129
+ @override
130
+ Future<void> stopActiveRecording({bool notifyEnded = false}) async {
131
+ _status = _status.copyWith(
132
+ active: false,
133
+ paused: false,
134
+ sessionId: null,
135
+ errorMessage: null,
136
+ floatingToolbarVisible: false,
137
+ );
138
+ notifyListeners();
139
+ }
140
+ }
141
+
142
+ class AndroidRecordingBridge extends RecordingBridge {
143
+ static const MethodChannel _channel = MethodChannel('neoagent/recordings');
144
+
145
+ RecordingRuntimeStatus _status = RecordingRuntimeStatus(
146
+ supportsScreenAndMic: false,
147
+ supportsBackgroundMic: true,
148
+ platformLabel: 'Android background recorder',
149
+ backgroundRuntimeActive: true,
150
+ );
151
+
152
+ @override
153
+ RecordingRuntimeStatus get status => _status;
154
+
155
+ void _log(
156
+ String event, {
157
+ Map<String, Object?> data = const <String, Object?>{},
158
+ Object? error,
159
+ StackTrace? stackTrace,
160
+ }) {
161
+ AppDiagnostics.log(
162
+ 'recording.bridge.android',
163
+ event,
164
+ data: data,
165
+ error: error,
166
+ stackTrace: stackTrace,
167
+ );
168
+ }
169
+
170
+ @override
171
+ Future<void> refreshStatus() async {
172
+ _log('refresh_status.request');
173
+ final result = await _channel.invokeMapMethod<String, dynamic>('status');
174
+ _status = _status.copyWith(
175
+ active: result?['active'] == true,
176
+ paused: result?['paused'] == true,
177
+ sessionId: result?['sessionId']?.toString(),
178
+ errorMessage: result?['errorMessage']?.toString(),
179
+ startedAt: _parseDate(result?['startedAt']),
180
+ activeSources: result?['active'] == true
181
+ ? const <String>['microphone']
182
+ : const <String>[],
183
+ );
184
+ notifyListeners();
185
+ }
186
+
187
+ @override
188
+ Future<void> startBackgroundRecording({
189
+ required String baseUrl,
190
+ required String sessionCookie,
191
+ required String sessionId,
192
+ }) async {
193
+ _log('start_background.request', data: <String, Object?>{
194
+ 'sessionId': sessionId,
195
+ 'baseUrl': baseUrl,
196
+ 'hasSessionCookie': sessionCookie.isNotEmpty,
197
+ });
198
+ await _channel.invokeMethod('startBackgroundRecording', <String, dynamic>{
199
+ 'backendUrl': baseUrl,
200
+ 'sessionCookie': sessionCookie,
201
+ 'sessionId': sessionId,
202
+ });
203
+ await refreshStatus();
204
+ }
205
+
206
+ @override
207
+ Future<void> pauseBackgroundRecording() async {
208
+ await _channel.invokeMethod('pauseBackgroundRecording');
209
+ await refreshStatus();
210
+ }
211
+
212
+ @override
213
+ Future<void> resumeBackgroundRecording() async {
214
+ await _channel.invokeMethod('resumeBackgroundRecording');
215
+ await refreshStatus();
216
+ }
217
+
218
+ @override
219
+ Future<void> stopActiveRecording({bool notifyEnded = false}) async {
220
+ final sessionId = _status.sessionId;
221
+ await _channel.invokeMethod('stopBackgroundRecording');
222
+ await refreshStatus();
223
+ if (notifyEnded && sessionId != null && onRecordingStopped != null) {
224
+ await onRecordingStopped!(sessionId);
225
+ }
226
+ }
227
+
228
+ @override
229
+ Future<void> startDesktopAudioRecording({
230
+ required String baseUrl,
231
+ required String sessionCookie,
232
+ required String sessionId,
233
+ bool autoShowToolbar = true,
234
+ }) async {
235
+ throw const RecordingBridgeException(
236
+ 'Native desktop recording is not available on Android.',
237
+ );
238
+ }
239
+
240
+ @override
241
+ Future<void> pauseDesktopRecording() async {
242
+ throw const RecordingBridgeException(
243
+ 'Desktop recording controls are not available on Android.',
244
+ );
245
+ }
246
+
247
+ @override
248
+ Future<void> resumeDesktopRecording() async {
249
+ throw const RecordingBridgeException(
250
+ 'Desktop recording controls are not available on Android.',
251
+ );
252
+ }
253
+
254
+ @override
255
+ Future<void> showFloatingToolbar() async {}
256
+
257
+ @override
258
+ Future<void> hideFloatingToolbar() async {}
259
+
260
+ @override
261
+ Future<void> openMicrophoneSettings() async {
262
+ throw const RecordingBridgeException(
263
+ 'Manage Android microphone permissions from system settings.',
264
+ );
265
+ }
266
+
267
+ @override
268
+ Future<void> openSystemAudioSettings() async {
269
+ throw const RecordingBridgeException(
270
+ 'System audio capture settings are only available on desktop.',
271
+ );
272
+ }
273
+
274
+ @override
275
+ Future<void> startWebRecording({
276
+ required String baseUrl,
277
+ required String sessionId,
278
+ }) async {
279
+ throw const RecordingBridgeException(
280
+ 'Screen and microphone recording is available on web only.',
281
+ );
282
+ }
283
+
284
+ @override
285
+ Future<void> startWebMicrophoneRecording({
286
+ required String baseUrl,
287
+ required String sessionId,
288
+ }) async {
289
+ throw const RecordingBridgeException(
290
+ 'Microphone-only browser recording is available on web only.',
291
+ );
292
+ }
293
+
294
+ DateTime? _parseDate(Object? raw) {
295
+ if (raw == null) {
296
+ return null;
297
+ }
298
+ return DateTime.tryParse(raw.toString());
299
+ }
300
+ }
301
+
302
+ class DesktopRecordingBridge extends RecordingBridge {
303
+ DesktopRecordingBridge()
304
+ : _micCapture = MicAudioCapture(
305
+ config: MicAudioConfig(
306
+ sampleRate: _sampleRate,
307
+ channels: _channels,
308
+ bitDepth: 16,
309
+ gainBoost: 1.4,
310
+ inputVolume: 1.0,
311
+ ),
312
+ ),
313
+ _systemCapture = SystemAudioCapture(
314
+ config: SystemAudioConfig(
315
+ sampleRate: _sampleRate,
316
+ channels: _channels,
317
+ ),
318
+ );
319
+
320
+ static const int _sampleRate = 16000;
321
+ static const int _channels = 1;
322
+ static const int _bytesPerSample = 2;
323
+ static const int _chunkDurationMs = 4000;
324
+
325
+ final MicAudioCapture _micCapture;
326
+ final SystemAudioCapture _systemCapture;
327
+ http.Client _httpClient = http.Client();
328
+
329
+ RecordingRuntimeStatus _status = RecordingRuntimeStatus(
330
+ supportsScreenAndMic: false,
331
+ supportsBackgroundMic: false,
332
+ supportsSystemAudio: true,
333
+ supportsDesktopBackgroundRuntime: true,
334
+ supportsFloatingToolbar: true,
335
+ supportsGlobalHotkeys: true,
336
+ platformLabel: _desktopPlatformLabel(),
337
+ backgroundRuntimeActive: true,
338
+ );
339
+
340
+ final Map<String, List<int>> _pcmBuffers = <String, List<int>>{
341
+ 'microphone': <int>[],
342
+ 'system': <int>[],
343
+ };
344
+ final Map<String, int> _nextSequenceBySource = <String, int>{
345
+ 'microphone': 0,
346
+ 'system': 0,
347
+ };
348
+ final Map<String, int> _lastEndMsBySource = <String, int>{
349
+ 'microphone': 0,
350
+ 'system': 0,
351
+ };
352
+ final Map<String, Future<void>> _uploadQueueBySource = <String, Future<void>>{
353
+ 'microphone': Future<void>.value(),
354
+ 'system': Future<void>.value(),
355
+ };
356
+ final Map<String, int> _bytesPerSecondBySource = <String, int>{
357
+ 'microphone': _sampleRate * _channels * _bytesPerSample,
358
+ 'system': _sampleRate * _channels * _bytesPerSample,
359
+ };
360
+
361
+ StreamSubscription<Uint8List>? _micAudioSub;
362
+ StreamSubscription<Uint8List>? _systemAudioSub;
363
+ StreamSubscription<MicAudioStatus>? _micStatusSub;
364
+ StreamSubscription<SystemAudioStatus>? _systemStatusSub;
365
+ StreamSubscription<DecibelData>? _micLevelSub;
366
+ StreamSubscription<DecibelData>? _systemLevelSub;
367
+
368
+ String? _baseUrl;
369
+ String? _sessionCookie;
370
+ String? _sessionId;
371
+
372
+ @override
373
+ RecordingRuntimeStatus get status => _status;
374
+
375
+ @override
376
+ Future<void> refreshStatus() async {
377
+ final availableInputDevices = await _loadInputDevices();
378
+ final selectedInput = availableInputDevices.cast<RecordingInputDevice?>()
379
+ .firstWhere(
380
+ (device) => device?.isDefault == true,
381
+ orElse: () => availableInputDevices.isEmpty
382
+ ? null
383
+ : availableInputDevices.first,
384
+ );
385
+ _status = _status.copyWith(
386
+ availableInputDevices: availableInputDevices,
387
+ selectedInputDeviceId: selectedInput?.id,
388
+ selectedInputDeviceName: selectedInput?.name,
389
+ );
390
+ notifyListeners();
391
+ }
392
+
393
+ @override
394
+ Future<void> startDesktopAudioRecording({
395
+ required String baseUrl,
396
+ required String sessionCookie,
397
+ required String sessionId,
398
+ bool autoShowToolbar = true,
399
+ }) async {
400
+ if (_status.active) {
401
+ throw const RecordingBridgeException(
402
+ 'A desktop recording is already in progress.',
403
+ );
404
+ }
405
+
406
+ _baseUrl = baseUrl;
407
+ _sessionCookie = sessionCookie;
408
+ _sessionId = sessionId;
409
+ _resetBuffers();
410
+ await refreshStatus();
411
+
412
+ try {
413
+ await _ensureDesktopPermissions();
414
+ await _startStreams();
415
+ _status = _status.copyWith(
416
+ active: true,
417
+ paused: false,
418
+ sessionId: sessionId,
419
+ startedAt: DateTime.now(),
420
+ errorMessage: null,
421
+ activeSources: const <String>['microphone', 'system'],
422
+ floatingToolbarVisible: autoShowToolbar,
423
+ );
424
+ notifyListeners();
425
+ } catch (error, stackTrace) {
426
+ _log('start_desktop.failed', error: error, stackTrace: stackTrace);
427
+ _status = _status.copyWith(
428
+ active: false,
429
+ paused: false,
430
+ sessionId: null,
431
+ startedAt: null,
432
+ activeSources: const <String>[],
433
+ errorMessage: error.toString(),
434
+ );
435
+ notifyListeners();
436
+ rethrow;
437
+ }
438
+ }
439
+
440
+ @override
441
+ Future<void> pauseDesktopRecording() async {
442
+ if (!_status.active || _status.paused) {
443
+ return;
444
+ }
445
+ await _stopStreams(flushPending: true);
446
+ await Future.wait(_uploadQueueBySource.values);
447
+ _status = _status.copyWith(
448
+ paused: true,
449
+ activeSources: const <String>[],
450
+ microphoneLevelDb: -120,
451
+ systemAudioLevelDb: -120,
452
+ );
453
+ notifyListeners();
454
+ }
455
+
456
+ @override
457
+ Future<void> resumeDesktopRecording() async {
458
+ if (!_status.active || !_status.paused) {
459
+ return;
460
+ }
461
+ await _ensureDesktopPermissions();
462
+ await _startStreams();
463
+ _status = _status.copyWith(
464
+ paused: false,
465
+ activeSources: const <String>['microphone', 'system'],
466
+ errorMessage: null,
467
+ );
468
+ notifyListeners();
469
+ }
470
+
471
+ @override
472
+ Future<void> showFloatingToolbar() async {
473
+ _status = _status.copyWith(floatingToolbarVisible: true);
474
+ notifyListeners();
475
+ }
476
+
477
+ @override
478
+ Future<void> hideFloatingToolbar() async {
479
+ _status = _status.copyWith(floatingToolbarVisible: false);
480
+ notifyListeners();
481
+ }
482
+
483
+ @override
484
+ Future<void> openMicrophoneSettings() async {
485
+ await _openPlatformSettings(
486
+ macUrl:
487
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
488
+ windowsCommand: const <String>[
489
+ 'cmd',
490
+ '/c',
491
+ 'start',
492
+ 'ms-settings:privacy-microphone',
493
+ ],
494
+ linuxCommands: const <List<String>>[
495
+ <String>['xdg-open', 'settings://privacy'],
496
+ <String>['gnome-control-center', 'privacy'],
497
+ <String>['gnome-control-center', 'sound'],
498
+ <String>['pavucontrol'],
499
+ ],
500
+ failureMessage: 'Could not open microphone settings.',
501
+ );
502
+ }
503
+
504
+ @override
505
+ Future<void> openSystemAudioSettings() async {
506
+ await _openPlatformSettings(
507
+ macUrl:
508
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
509
+ windowsCommand: const <String>[
510
+ 'cmd',
511
+ '/c',
512
+ 'start',
513
+ 'ms-settings:sound',
514
+ ],
515
+ linuxCommands: const <List<String>>[
516
+ <String>['xdg-open', 'settings://sound'],
517
+ <String>['gnome-control-center', 'sound'],
518
+ <String>['pavucontrol'],
519
+ ],
520
+ failureMessage: 'Could not open system audio settings.',
521
+ );
522
+ }
523
+
524
+ @override
525
+ Future<void> stopActiveRecording({bool notifyEnded = false}) async {
526
+ if (!_status.active) {
527
+ return;
528
+ }
529
+ final sessionId = _sessionId;
530
+ await _stopStreams(flushPending: true);
531
+ await Future.wait(_uploadQueueBySource.values);
532
+ _status = _status.copyWith(
533
+ active: false,
534
+ paused: false,
535
+ sessionId: null,
536
+ startedAt: null,
537
+ activeSources: const <String>[],
538
+ microphoneLevelDb: -120,
539
+ systemAudioLevelDb: -120,
540
+ floatingToolbarVisible: false,
541
+ );
542
+ notifyListeners();
543
+ if (notifyEnded && sessionId != null && onRecordingStopped != null) {
544
+ await onRecordingStopped!(sessionId);
545
+ }
546
+ }
547
+
548
+ @override
549
+ Future<void> pauseBackgroundRecording() async {
550
+ throw const RecordingBridgeException(
551
+ 'Android background recording controls are not available on desktop.',
552
+ );
553
+ }
554
+
555
+ @override
556
+ Future<void> resumeBackgroundRecording() async {
557
+ throw const RecordingBridgeException(
558
+ 'Android background recording controls are not available on desktop.',
559
+ );
560
+ }
561
+
562
+ @override
563
+ Future<void> startBackgroundRecording({
564
+ required String baseUrl,
565
+ required String sessionCookie,
566
+ required String sessionId,
567
+ }) async {
568
+ throw const RecordingBridgeException(
569
+ 'Android background recording is not available on desktop.',
570
+ );
571
+ }
572
+
573
+ @override
574
+ Future<void> startWebRecording({
575
+ required String baseUrl,
576
+ required String sessionId,
577
+ }) async {
578
+ throw const RecordingBridgeException(
579
+ 'Screen and microphone recording is available on web only.',
580
+ );
581
+ }
582
+
583
+ @override
584
+ Future<void> startWebMicrophoneRecording({
585
+ required String baseUrl,
586
+ required String sessionId,
587
+ }) async {
588
+ throw const RecordingBridgeException(
589
+ 'Microphone-only browser recording is available on web only.',
590
+ );
591
+ }
592
+
593
+ @override
594
+ void dispose() {
595
+ unawaited(_disposeDesktopBridge());
596
+ super.dispose();
597
+ }
598
+
599
+ Future<void> _disposeDesktopBridge() async {
600
+ try {
601
+ await _stopStreams(
602
+ flushPending: false,
603
+ ).timeout(const Duration(seconds: 2));
604
+ } on TimeoutException {
605
+ _log('dispose.stop_streams.timeout');
606
+ } catch (error, stackTrace) {
607
+ _log('dispose.stop_streams.failed', error: error, stackTrace: stackTrace);
608
+ }
609
+
610
+ try {
611
+ await _micCapture.stopCapture();
612
+ } catch (_) {}
613
+ try {
614
+ await _systemCapture.stopCapture();
615
+ } catch (_) {}
616
+
617
+ _httpClient.close();
618
+ }
619
+
620
+ Future<void> _ensureDesktopPermissions() async {
621
+ final micGranted = await _requestMicPermission();
622
+ final systemGranted = await _requestSystemPermission();
623
+ if (!micGranted || !systemGranted) {
624
+ throw RecordingBridgeException(
625
+ _permissionFailureMessage(
626
+ micGranted: micGranted,
627
+ systemGranted: systemGranted,
628
+ ),
629
+ );
630
+ }
631
+ }
632
+
633
+ Future<bool> _requestMicPermission() async {
634
+ try {
635
+ final granted = await _micCapture.requestPermissions();
636
+ _status = _status.copyWith(
637
+ microphonePermission: granted
638
+ ? RecordingPermissionState.granted
639
+ : RecordingPermissionState.denied,
640
+ );
641
+ return granted;
642
+ } catch (error) {
643
+ _status = _status.copyWith(
644
+ microphonePermission: _permissionStateFromError(error),
645
+ );
646
+ return false;
647
+ }
648
+ }
649
+
650
+ Future<bool> _requestSystemPermission() async {
651
+ try {
652
+ final granted = await _systemCapture.requestPermissions();
653
+ _status = _status.copyWith(
654
+ systemAudioPermission: granted
655
+ ? RecordingPermissionState.granted
656
+ : RecordingPermissionState.denied,
657
+ );
658
+ return granted;
659
+ } catch (error) {
660
+ _status = _status.copyWith(
661
+ systemAudioPermission: _permissionStateFromError(error),
662
+ );
663
+ return false;
664
+ }
665
+ }
666
+
667
+ Future<List<RecordingInputDevice>> _loadInputDevices() async {
668
+ try {
669
+ final devices = await _micCapture.getAvailableInputDevices();
670
+ return devices
671
+ .map(
672
+ (device) => RecordingInputDevice(
673
+ id: device.id,
674
+ name: device.name,
675
+ kind: device.type.toString(),
676
+ channelCount: device.channelCount,
677
+ isDefault: device.isDefault,
678
+ ),
679
+ )
680
+ .toList();
681
+ } catch (error, stackTrace) {
682
+ _log('input_devices.failed', error: error, stackTrace: stackTrace);
683
+ return const <RecordingInputDevice>[];
684
+ }
685
+ }
686
+
687
+ Future<void> _startStreams() async {
688
+ await _micCapture.startCapture();
689
+ await _systemCapture.startCapture();
690
+
691
+ _micAudioSub = _micCapture.audioStream?.listen(
692
+ (bytes) => _handleAudioChunk('microphone', bytes),
693
+ onError: (Object error, StackTrace stackTrace) {
694
+ _handleRuntimeError('microphone.stream', error, stackTrace);
695
+ },
696
+ );
697
+ _systemAudioSub = _systemCapture.audioStream?.listen(
698
+ (bytes) => _handleAudioChunk('system', bytes),
699
+ onError: (Object error, StackTrace stackTrace) {
700
+ _handleRuntimeError('system.stream', error, stackTrace);
701
+ },
702
+ );
703
+ _micStatusSub = _micCapture.statusStream?.listen((status) {
704
+ _status = _status.copyWith(
705
+ selectedInputDeviceName: status.deviceName,
706
+ );
707
+ notifyListeners();
708
+ });
709
+ _systemStatusSub = _systemCapture.statusStream?.listen((_) {});
710
+ _micLevelSub = _micCapture.decibelStream?.listen((level) {
711
+ _status = _status.copyWith(microphoneLevelDb: level.decibel);
712
+ notifyListeners();
713
+ });
714
+ _systemLevelSub = _systemCapture.decibelStream?.listen((level) {
715
+ _status = _status.copyWith(systemAudioLevelDb: level.decibel);
716
+ notifyListeners();
717
+ });
718
+ }
719
+
720
+ void _handleAudioChunk(String sourceKey, Uint8List bytes) {
721
+ final buffer = _pcmBuffers[sourceKey]!;
722
+ buffer.addAll(bytes);
723
+ final chunkByteTarget =
724
+ (_bytesPerSecondBySource[sourceKey]! * _chunkDurationMs) ~/ 1000;
725
+ while (buffer.length >= chunkByteTarget) {
726
+ final pcmBytes = Uint8List.fromList(buffer.sublist(0, chunkByteTarget));
727
+ buffer.removeRange(0, chunkByteTarget);
728
+ _queueUpload(
729
+ sourceKey: sourceKey,
730
+ pcmBytes: pcmBytes,
731
+ byteLength: chunkByteTarget,
732
+ );
733
+ }
734
+ }
735
+
736
+ void _queueUpload({
737
+ required String sourceKey,
738
+ required Uint8List pcmBytes,
739
+ required int byteLength,
740
+ }) {
741
+ final startMs = _lastEndMsBySource[sourceKey] ?? 0;
742
+ final durationMs =
743
+ (byteLength * 1000) ~/ _bytesPerSecondBySource[sourceKey]!;
744
+ final endMs = startMs + durationMs;
745
+ final sequence = _nextSequenceBySource[sourceKey] ?? 0;
746
+ _nextSequenceBySource[sourceKey] = sequence + 1;
747
+ _lastEndMsBySource[sourceKey] = endMs;
748
+
749
+ final previous = _uploadQueueBySource[sourceKey] ?? Future<void>.value();
750
+ _uploadQueueBySource[sourceKey] = previous
751
+ // Keep the queue moving even if a previous upload failed.
752
+ .catchError((Object _, StackTrace __) {})
753
+ .then((_) async {
754
+ try {
755
+ await _uploadChunk(
756
+ sourceKey: sourceKey,
757
+ sequence: sequence,
758
+ startMs: startMs,
759
+ endMs: endMs,
760
+ bytes: _wrapPcmAsWav(pcmBytes),
761
+ );
762
+ } catch (error, stackTrace) {
763
+ _handleRuntimeError('$sourceKey.upload', error, stackTrace);
764
+ }
765
+ });
766
+ }
767
+
768
+ Future<void> _uploadChunk({
769
+ required String sourceKey,
770
+ required int sequence,
771
+ required int startMs,
772
+ required int endMs,
773
+ required Uint8List bytes,
774
+ }) async {
775
+ final sessionId = _sessionId;
776
+ final baseUrl = _baseUrl;
777
+ if (sessionId == null || baseUrl == null || baseUrl.trim().isEmpty) {
778
+ throw const RecordingBridgeException(
779
+ 'Desktop recording session is not initialized.',
780
+ );
781
+ }
782
+
783
+ final uri = Uri.parse(
784
+ '${baseUrl.replaceFirst(RegExp(r'/$'), '')}/api/recordings/$sessionId/chunks',
785
+ );
786
+
787
+ for (var attempt = 0; attempt < 3; attempt += 1) {
788
+ try {
789
+ final headers = <String, String>{
790
+ 'Content-Type': 'audio/wav',
791
+ 'X-Recording-Source-Key': sourceKey,
792
+ 'X-Recording-Sequence': '$sequence',
793
+ 'X-Recording-Start-Ms': '$startMs',
794
+ 'X-Recording-End-Ms': '$endMs',
795
+ if ((_sessionCookie ?? '').trim().isNotEmpty)
796
+ 'Cookie': _sessionCookie!.trim(),
797
+ };
798
+ final response = await _httpClient
799
+ .post(uri, headers: headers, body: bytes)
800
+ .timeout(const Duration(seconds: 20));
801
+ if (response.statusCode < 200 || response.statusCode >= 300) {
802
+ throw RecordingBridgeException(
803
+ 'Chunk upload failed with status ${response.statusCode}.',
804
+ );
805
+ }
806
+ return;
807
+ } on TimeoutException catch (error, stackTrace) {
808
+ _log(
809
+ 'upload.timeout',
810
+ data: <String, Object?>{
811
+ 'sourceKey': sourceKey,
812
+ 'sequence': sequence,
813
+ 'attempt': attempt + 1,
814
+ },
815
+ error: error,
816
+ stackTrace: stackTrace,
817
+ );
818
+ _httpClient.close();
819
+ _httpClient = http.Client();
820
+ if (attempt == 2) {
821
+ throw const RecordingBridgeException('Chunk upload timed out.');
822
+ }
823
+ } catch (error) {
824
+ if (attempt == 2) {
825
+ rethrow;
826
+ }
827
+ await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
828
+ }
829
+ }
830
+ }
831
+
832
+ Future<void> _stopStreams({required bool flushPending}) async {
833
+ await _micAudioSub?.cancel();
834
+ await _systemAudioSub?.cancel();
835
+ await _micStatusSub?.cancel();
836
+ await _systemStatusSub?.cancel();
837
+ await _micLevelSub?.cancel();
838
+ await _systemLevelSub?.cancel();
839
+ _micAudioSub = null;
840
+ _systemAudioSub = null;
841
+ _micStatusSub = null;
842
+ _systemStatusSub = null;
843
+ _micLevelSub = null;
844
+ _systemLevelSub = null;
845
+
846
+ if (_micCapture.isRecording) {
847
+ await _micCapture.stopCapture();
848
+ }
849
+ if (_systemCapture.isRecording) {
850
+ await _systemCapture.stopCapture();
851
+ }
852
+
853
+ if (flushPending) {
854
+ for (final entry in _pcmBuffers.entries) {
855
+ if (entry.value.isEmpty) {
856
+ continue;
857
+ }
858
+ final remaining = Uint8List.fromList(entry.value);
859
+ entry.value.clear();
860
+ _queueUpload(
861
+ sourceKey: entry.key,
862
+ pcmBytes: remaining,
863
+ byteLength: remaining.length,
864
+ );
865
+ }
866
+ } else {
867
+ _resetBuffers();
868
+ }
869
+ }
870
+
871
+ void _resetBuffers() {
872
+ for (final sourceKey in _pcmBuffers.keys) {
873
+ _pcmBuffers[sourceKey] = <int>[];
874
+ _nextSequenceBySource[sourceKey] = 0;
875
+ _lastEndMsBySource[sourceKey] = 0;
876
+ _uploadQueueBySource[sourceKey] = Future<void>.value();
877
+ }
878
+ }
879
+
880
+ Uint8List _wrapPcmAsWav(Uint8List pcmBytes) {
881
+ final totalLength = pcmBytes.length + 44;
882
+ final data = ByteData(totalLength);
883
+ void writeAscii(int offset, String value) {
884
+ for (var index = 0; index < value.length; index += 1) {
885
+ data.setUint8(offset + index, value.codeUnitAt(index));
886
+ }
887
+ }
888
+
889
+ writeAscii(0, 'RIFF');
890
+ data.setUint32(4, totalLength - 8, Endian.little);
891
+ writeAscii(8, 'WAVE');
892
+ writeAscii(12, 'fmt ');
893
+ data.setUint32(16, 16, Endian.little);
894
+ data.setUint16(20, 1, Endian.little);
895
+ data.setUint16(22, _channels, Endian.little);
896
+ data.setUint32(24, _sampleRate, Endian.little);
897
+ data.setUint32(
898
+ 28,
899
+ _sampleRate * _channels * _bytesPerSample,
900
+ Endian.little,
901
+ );
902
+ data.setUint16(32, _channels * _bytesPerSample, Endian.little);
903
+ data.setUint16(34, _bytesPerSample * 8, Endian.little);
904
+ writeAscii(36, 'data');
905
+ data.setUint32(40, pcmBytes.length, Endian.little);
906
+ for (var index = 0; index < pcmBytes.length; index += 1) {
907
+ data.setUint8(44 + index, pcmBytes[index]);
908
+ }
909
+ return data.buffer.asUint8List();
910
+ }
911
+
912
+ RecordingPermissionState _permissionStateFromError(Object error) {
913
+ final message = error.toString().toLowerCase();
914
+ if (message.contains('restart')) {
915
+ return RecordingPermissionState.needsRestart;
916
+ }
917
+ if (message.contains('permission')) {
918
+ return RecordingPermissionState.denied;
919
+ }
920
+ return RecordingPermissionState.unknown;
921
+ }
922
+
923
+ String _permissionFailureMessage({
924
+ required bool micGranted,
925
+ required bool systemGranted,
926
+ }) {
927
+ if (!micGranted && !systemGranted) {
928
+ return 'Grant microphone and system audio permissions before starting desktop recording.';
929
+ }
930
+ if (!micGranted) {
931
+ return 'Grant microphone permission before starting desktop recording.';
932
+ }
933
+ return 'Grant system audio permission before starting desktop recording.';
934
+ }
935
+
936
+ Future<void> _openPlatformSettings({
937
+ required String macUrl,
938
+ required List<String> windowsCommand,
939
+ required List<List<String>> linuxCommands,
940
+ required String failureMessage,
941
+ }) async {
942
+ try {
943
+ ProcessResult result;
944
+ if (Platform.isMacOS) {
945
+ result = await Process.run('open', <String>[macUrl]);
946
+ } else if (Platform.isWindows) {
947
+ result = await Process.run(
948
+ windowsCommand.first,
949
+ windowsCommand.sublist(1),
950
+ runInShell: true,
951
+ );
952
+ } else if (Platform.isLinux) {
953
+ var success = false;
954
+ for (final command in linuxCommands) {
955
+ if (command.isEmpty) {
956
+ continue;
957
+ }
958
+ result = await Process.run(command.first, command.sublist(1));
959
+ if (result.exitCode == 0) {
960
+ success = true;
961
+ break;
962
+ }
963
+ }
964
+ if (!success) {
965
+ throw RecordingBridgeException(failureMessage);
966
+ }
967
+ return;
968
+ } else {
969
+ throw RecordingBridgeException(failureMessage);
970
+ }
971
+ if (result.exitCode != 0) {
972
+ throw RecordingBridgeException(failureMessage);
973
+ }
974
+ } catch (error) {
975
+ if (error is RecordingBridgeException) {
976
+ rethrow;
977
+ }
978
+ throw RecordingBridgeException(failureMessage);
979
+ }
980
+ }
981
+
982
+ void _handleRuntimeError(
983
+ String event,
984
+ Object error,
985
+ StackTrace stackTrace,
986
+ ) {
987
+ _log(event, error: error, stackTrace: stackTrace);
988
+ _status = _status.copyWith(errorMessage: error.toString());
989
+ notifyListeners();
990
+ }
991
+
992
+ void _log(
993
+ String event, {
994
+ Map<String, Object?> data = const <String, Object?>{},
995
+ Object? error,
996
+ StackTrace? stackTrace,
997
+ }) {
998
+ AppDiagnostics.log(
999
+ 'recording.bridge.desktop',
1000
+ event,
1001
+ data: data,
1002
+ error: error,
1003
+ stackTrace: stackTrace,
1004
+ );
1005
+ }
1006
+
1007
+ static String _desktopPlatformLabel() {
1008
+ switch (defaultTargetPlatform) {
1009
+ case TargetPlatform.macOS:
1010
+ return 'Desktop dual-source recorder (macOS)';
1011
+ case TargetPlatform.windows:
1012
+ return 'Desktop dual-source recorder (Windows)';
1013
+ case TargetPlatform.linux:
1014
+ return 'Desktop dual-source recorder (Linux)';
1015
+ default:
1016
+ return 'Desktop dual-source recorder';
1017
+ }
1018
+ }
1019
+ }