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,586 @@
1
+ package com.neoagent.flutter_app.recording
2
+
3
+ import android.Manifest
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.app.PendingIntent
8
+ import android.app.Service
9
+ import android.content.Context
10
+ import android.content.Intent
11
+ import android.content.pm.PackageManager
12
+ import android.media.AudioFormat
13
+ import android.media.AudioRecord
14
+ import android.media.MediaRecorder
15
+ import android.media.audiofx.AutomaticGainControl
16
+ import android.media.audiofx.NoiseSuppressor
17
+ import android.os.Build
18
+ import android.os.IBinder
19
+ import androidx.core.app.NotificationCompat
20
+ import androidx.core.content.ContextCompat
21
+ import kotlinx.coroutines.CoroutineScope
22
+ import kotlinx.coroutines.Dispatchers
23
+ import kotlinx.coroutines.Job
24
+ import kotlinx.coroutines.SupervisorJob
25
+ import kotlinx.coroutines.cancel
26
+ import kotlinx.coroutines.delay
27
+ import kotlinx.coroutines.isActive
28
+ import kotlinx.coroutines.launch
29
+ import kotlinx.coroutines.sync.Mutex
30
+ import kotlinx.coroutines.sync.withLock
31
+ import org.json.JSONObject
32
+ import java.io.ByteArrayOutputStream
33
+ import java.io.File
34
+ import java.io.OutputStreamWriter
35
+ import java.time.Instant
36
+
37
+ class RecordingForegroundService : Service() {
38
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
39
+ private val stateStore by lazy { RecordingStateStore(this) }
40
+ private val uploadClient = RecordingUploadClient()
41
+ private val uploadMutex = Mutex()
42
+
43
+ private var config: RecordingConfig? = null
44
+ private var audioRecord: AudioRecord? = null
45
+ private var captureJob: Job? = null
46
+ private var noiseSuppressor: NoiseSuppressor? = null
47
+ private var automaticGainControl: AutomaticGainControl? = null
48
+
49
+ override fun onBind(intent: Intent?): IBinder? = null
50
+
51
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
52
+ when (intent?.action) {
53
+ ACTION_START -> startNewRecording(intent)
54
+ ACTION_RESTORE -> restoreRecording()
55
+ ACTION_PAUSE -> pauseRecording()
56
+ ACTION_RESUME -> resumeRecording()
57
+ ACTION_STOP -> stopRecording(finalize = true)
58
+ }
59
+ return START_STICKY
60
+ }
61
+
62
+ override fun onDestroy() {
63
+ captureJob?.cancel()
64
+ stopRecorder()
65
+ serviceScope.cancel()
66
+ super.onDestroy()
67
+ }
68
+
69
+ private fun startNewRecording(intent: Intent) {
70
+ ensureMicPermission()
71
+ val backendUrl = intent.getStringExtra(EXTRA_BACKEND_URL).orEmpty()
72
+ val sessionCookie = intent.getStringExtra(EXTRA_SESSION_COOKIE).orEmpty()
73
+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID).orEmpty()
74
+ require(backendUrl.isNotBlank()) { "Backend URL is required." }
75
+ require(sessionCookie.isNotBlank()) { "Session cookie is required." }
76
+ require(sessionId.isNotBlank()) { "Session ID is required." }
77
+
78
+ config = RecordingConfig(
79
+ backendUrl = backendUrl,
80
+ sessionCookie = sessionCookie,
81
+ sessionId = sessionId,
82
+ active = true,
83
+ paused = false,
84
+ nextSequence = 0,
85
+ capturedAudioMs = 0L,
86
+ startedAt = Instant.now().toString(),
87
+ errorMessage = null,
88
+ )
89
+ stateStore.saveConfig(config!!)
90
+ startForegroundServiceUi()
91
+ serviceScope.launch {
92
+ drainPendingUploads()
93
+ startCaptureLoop()
94
+ }
95
+ }
96
+
97
+ private fun restoreRecording() {
98
+ ensureMicPermission()
99
+ val restored = stateStore.loadConfig() ?: return
100
+ config = restored.copy(active = true, paused = false, errorMessage = null)
101
+ stateStore.saveConfig(config!!)
102
+ startForegroundServiceUi()
103
+ serviceScope.launch {
104
+ drainPendingUploads()
105
+ startCaptureLoop()
106
+ }
107
+ }
108
+
109
+ private fun pauseRecording() {
110
+ val current = config ?: return
111
+ config = current.copy(active = true, paused = true)
112
+ stateStore.saveConfig(config!!)
113
+ captureJob?.cancel()
114
+ stopRecorder()
115
+ updateNotification()
116
+ }
117
+
118
+ private fun resumeRecording() {
119
+ val current = config ?: stateStore.loadConfig() ?: return
120
+ config = current.copy(active = true, paused = false, errorMessage = null)
121
+ stateStore.saveConfig(config!!)
122
+ startForegroundServiceUi()
123
+ serviceScope.launch {
124
+ drainPendingUploads()
125
+ startCaptureLoop()
126
+ }
127
+ }
128
+
129
+ private fun stopRecording(finalize: Boolean) {
130
+ val current = config ?: stateStore.loadConfig() ?: return
131
+ serviceScope.launch {
132
+ captureJob?.cancel()
133
+ stopRecorder()
134
+ try {
135
+ drainPendingUploads()
136
+ if (finalize) {
137
+ uploadClient.finalizeSession(
138
+ backendUrl = current.backendUrl,
139
+ sessionCookie = current.sessionCookie,
140
+ sessionId = current.sessionId,
141
+ stopReason = "stopped",
142
+ )
143
+ }
144
+ stateStore.clear()
145
+ config = null
146
+ } catch (error: Exception) {
147
+ config = current.copy(
148
+ active = false,
149
+ paused = false,
150
+ errorMessage = error.message,
151
+ )
152
+ stateStore.saveConfig(config!!)
153
+ } finally {
154
+ stopForeground(STOP_FOREGROUND_REMOVE)
155
+ stopSelf()
156
+ }
157
+ }
158
+ }
159
+
160
+ private suspend fun startCaptureLoop() {
161
+ if (captureJob?.isActive == true) {
162
+ return
163
+ }
164
+ val current = config ?: return
165
+ val recorderConfig = buildRecorderConfig()
166
+ val sampleRate = recorderConfig.sampleRate
167
+ val record = recorderConfig.audioRecord
168
+ audioRecord = record
169
+ attachAudioEnhancers(record.audioSessionId)
170
+ record.startRecording()
171
+ if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
172
+ stopRecorder()
173
+ throw IllegalStateException("Microphone capture did not start correctly.")
174
+ }
175
+
176
+ captureJob = serviceScope.launch {
177
+ val readBuffer = ByteArray(maxOf(recorderConfig.bufferSize, 4096))
178
+ val chunkBuffer = ByteArrayOutputStream()
179
+ var chunkStartMs = config?.capturedAudioMs ?: 0L
180
+ var chunkBytes = 0L
181
+ var capturedBytes = millisToBytes(current.capturedAudioMs, sampleRate)
182
+
183
+ while (isActive) {
184
+ val read = record.read(readBuffer, 0, readBuffer.size)
185
+ if (read <= 0) {
186
+ delay(20)
187
+ continue
188
+ }
189
+ chunkBuffer.write(readBuffer, 0, read)
190
+ chunkBytes += read.toLong()
191
+ capturedBytes += read.toLong()
192
+ val currentConfig = config ?: break
193
+ val updatedCaptured = bytesToMillis(capturedBytes, sampleRate)
194
+ config = currentConfig.copy(capturedAudioMs = updatedCaptured)
195
+ stateStore.saveConfig(config!!)
196
+
197
+ if (bytesToMillis(chunkBytes, sampleRate) >= CHUNK_DURATION_MS) {
198
+ val chunkEndMs = chunkStartMs + bytesToMillis(chunkBytes, sampleRate)
199
+ flushChunk(
200
+ bytes = chunkBuffer.toByteArray(),
201
+ startMs = chunkStartMs,
202
+ endMs = chunkEndMs,
203
+ sampleRate = sampleRate,
204
+ )
205
+ chunkBuffer.reset()
206
+ chunkStartMs = chunkEndMs
207
+ chunkBytes = 0L
208
+ }
209
+ }
210
+
211
+ if (chunkBuffer.size() > 0) {
212
+ flushChunk(
213
+ bytes = chunkBuffer.toByteArray(),
214
+ startMs = chunkStartMs,
215
+ endMs = chunkStartMs + bytesToMillis(chunkBytes, sampleRate),
216
+ sampleRate = sampleRate,
217
+ )
218
+ }
219
+ }
220
+ updateNotification()
221
+ }
222
+
223
+ private fun flushChunk(bytes: ByteArray, startMs: Long, endMs: Long, sampleRate: Int) {
224
+ if (bytes.isEmpty()) {
225
+ return
226
+ }
227
+ val current = config ?: return
228
+ val sequence = current.nextSequence
229
+ val pendingDir = pendingDir(current.sessionId)
230
+ pendingDir.mkdirs()
231
+ val audioFile = File(pendingDir, "${sequence.toString().padStart(6, '0')}.wav")
232
+ val metaFile = File(pendingDir, "${sequence.toString().padStart(6, '0')}.json")
233
+
234
+ val audioTemp = File(audioFile.absolutePath + ".tmp")
235
+ val metaTemp = File(metaFile.absolutePath + ".tmp")
236
+
237
+ fun deleteTempFile(file: File, failureMessage: String) {
238
+ if (file.exists() && !file.delete()) {
239
+ android.util.Log.w(TAG, "$failureMessage: ${file.absolutePath}")
240
+ }
241
+ }
242
+
243
+ try {
244
+ audioTemp.writeBytes(wrapPcmAsWav(bytes, sampleRate))
245
+ val meta = JSONObject()
246
+ .put("sequence", sequence)
247
+ .put("startMs", startMs)
248
+ .put("endMs", endMs)
249
+ .put("mimeType", "audio/wav")
250
+ .put("sampleRate", sampleRate)
251
+ OutputStreamWriter(metaTemp.outputStream(), Charsets.UTF_8).use { writer ->
252
+ writer.write(meta.toString())
253
+ }
254
+
255
+ if (!audioTemp.renameTo(audioFile)) {
256
+ deleteTempFile(
257
+ audioTemp,
258
+ "Failed to remove orphan audio temp file after audio rename failure",
259
+ )
260
+ throw IllegalStateException("Failed to persist recording chunk audio file.")
261
+ }
262
+
263
+ if (!metaTemp.renameTo(metaFile)) {
264
+ deleteTempFile(
265
+ metaTemp,
266
+ "Failed to remove orphan metadata temp file after metadata rename failure",
267
+ )
268
+ if (audioFile.exists() && !audioFile.delete()) {
269
+ android.util.Log.w(TAG, "Failed to roll back persisted audio file after metadata rename failure: ${audioFile.absolutePath}")
270
+ }
271
+ throw IllegalStateException("Failed to persist recording chunk metadata file.")
272
+ }
273
+ } catch (err: Exception) {
274
+ deleteTempFile(audioTemp, "Failed to remove orphan audio temp file")
275
+ deleteTempFile(metaTemp, "Failed to remove orphan metadata temp file")
276
+ throw err
277
+ }
278
+
279
+ config = current.copy(nextSequence = sequence + 1)
280
+ stateStore.saveConfig(config!!)
281
+ serviceScope.launch {
282
+ drainPendingUploads()
283
+ }
284
+ }
285
+
286
+ private suspend fun drainPendingUploads() {
287
+ val current = config ?: return
288
+ uploadMutex.withLock {
289
+ val pendingDir = pendingDir(current.sessionId)
290
+ if (!pendingDir.exists()) {
291
+ return
292
+ }
293
+ val metaFiles = pendingDir.listFiles { _, name -> name.endsWith(".json") }
294
+ ?.sortedBy { it.nameWithoutExtension }
295
+ ?: emptyList()
296
+
297
+ for (metaFile in metaFiles) {
298
+ val audioFile = File(pendingDir, "${metaFile.nameWithoutExtension}.wav")
299
+ if (!audioFile.exists()) {
300
+ val currentConfig = config
301
+ if (currentConfig != null) {
302
+ config = currentConfig.copy(
303
+ errorMessage = "Missing audio file for pending chunk ${metaFile.nameWithoutExtension}",
304
+ )
305
+ stateStore.saveConfig(config!!)
306
+ }
307
+ if (!metaFile.delete()) {
308
+ android.util.Log.w(TAG, "Failed to remove orphan metadata file: ${metaFile.absolutePath}")
309
+ }
310
+ continue
311
+ }
312
+ val metaJson = JSONObject(metaFile.readText())
313
+ val meta = PendingChunkMeta(
314
+ sequence = metaJson.getInt("sequence"),
315
+ startMs = metaJson.getLong("startMs"),
316
+ endMs = metaJson.getLong("endMs"),
317
+ mimeType = metaJson.optString("mimeType", "audio/wav"),
318
+ )
319
+ retryUpload(audioFile, meta)
320
+ audioFile.delete()
321
+ metaFile.delete()
322
+ }
323
+ }
324
+ }
325
+
326
+ private suspend fun retryUpload(audioFile: File, meta: PendingChunkMeta) {
327
+ val current = config ?: return
328
+ var lastError: Exception? = null
329
+ repeat(5) { attempt ->
330
+ try {
331
+ uploadClient.uploadChunk(
332
+ backendUrl = current.backendUrl,
333
+ sessionCookie = current.sessionCookie,
334
+ sessionId = current.sessionId,
335
+ meta = meta,
336
+ file = audioFile,
337
+ )
338
+ return
339
+ } catch (error: Exception) {
340
+ lastError = error
341
+ val latest = config ?: current
342
+ config = latest.copy(errorMessage = error.message)
343
+ stateStore.saveConfig(config!!)
344
+ delay((attempt + 1) * 600L)
345
+ }
346
+ }
347
+ throw lastError ?: IllegalStateException("Upload failed.")
348
+ }
349
+
350
+ private fun startForegroundServiceUi() {
351
+ createChannel()
352
+ startForeground(NOTIFICATION_ID, buildNotification())
353
+ }
354
+
355
+ private fun updateNotification() {
356
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
357
+ manager.notify(NOTIFICATION_ID, buildNotification())
358
+ }
359
+
360
+ private fun buildNotification(): Notification {
361
+ val current = config
362
+ val openIntent = packageManager.getLaunchIntentForPackage(packageName)
363
+ val pendingIntent = PendingIntent.getActivity(
364
+ this,
365
+ 0,
366
+ openIntent,
367
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
368
+ )
369
+ val paused = current?.paused == true
370
+ return NotificationCompat.Builder(this, CHANNEL_ID)
371
+ .setSmallIcon(android.R.drawable.ic_btn_speak_now)
372
+ .setContentTitle(if (paused) "NeoAgent recording paused" else "NeoAgent recording")
373
+ .setContentText(
374
+ if (paused) {
375
+ "Background microphone capture is paused."
376
+ } else {
377
+ "Background microphone capture is running."
378
+ },
379
+ )
380
+ .setOngoing(true)
381
+ .setOnlyAlertOnce(true)
382
+ .setContentIntent(pendingIntent)
383
+ .build()
384
+ }
385
+
386
+ private fun createChannel() {
387
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
388
+ return
389
+ }
390
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
391
+ val channel = NotificationChannel(
392
+ CHANNEL_ID,
393
+ "NeoAgent recordings",
394
+ NotificationManager.IMPORTANCE_LOW,
395
+ )
396
+ manager.createNotificationChannel(channel)
397
+ }
398
+
399
+ private fun stopRecorder() {
400
+ try {
401
+ audioRecord?.stop()
402
+ } catch (_: Exception) {
403
+ }
404
+ noiseSuppressor?.release()
405
+ noiseSuppressor = null
406
+ automaticGainControl?.release()
407
+ automaticGainControl = null
408
+ audioRecord?.release()
409
+ audioRecord = null
410
+ }
411
+
412
+ private fun pendingDir(sessionId: String): File =
413
+ File(filesDir, "recording-pending/$sessionId")
414
+
415
+ private fun wrapPcmAsWav(pcmBytes: ByteArray, sampleRate: Int): ByteArray {
416
+ val byteRate = sampleRate * CHANNEL_COUNT * BYTES_PER_SAMPLE
417
+ val totalDataLen = pcmBytes.size + 36
418
+ return ByteArrayOutputStream().use { output ->
419
+ output.write("RIFF".toByteArray())
420
+ output.write(intToLittleEndian(totalDataLen))
421
+ output.write("WAVE".toByteArray())
422
+ output.write("fmt ".toByteArray())
423
+ output.write(intToLittleEndian(16))
424
+ output.write(shortToLittleEndian(1))
425
+ output.write(shortToLittleEndian(CHANNEL_COUNT.toShort()))
426
+ output.write(intToLittleEndian(sampleRate))
427
+ output.write(intToLittleEndian(byteRate))
428
+ output.write(shortToLittleEndian((CHANNEL_COUNT * BYTES_PER_SAMPLE).toShort()))
429
+ output.write(shortToLittleEndian((BYTES_PER_SAMPLE * 8).toShort()))
430
+ output.write("data".toByteArray())
431
+ output.write(intToLittleEndian(pcmBytes.size))
432
+ output.write(pcmBytes)
433
+ output.toByteArray()
434
+ }
435
+ }
436
+
437
+ private fun intToLittleEndian(value: Int): ByteArray = byteArrayOf(
438
+ (value and 0xff).toByte(),
439
+ (value shr 8 and 0xff).toByte(),
440
+ (value shr 16 and 0xff).toByte(),
441
+ (value shr 24 and 0xff).toByte(),
442
+ )
443
+
444
+ private fun shortToLittleEndian(value: Short): ByteArray = byteArrayOf(
445
+ (value.toInt() and 0xff).toByte(),
446
+ (value.toInt() shr 8 and 0xff).toByte(),
447
+ )
448
+
449
+ private fun ensureMicPermission() {
450
+ val granted = ContextCompat.checkSelfPermission(
451
+ this,
452
+ Manifest.permission.RECORD_AUDIO,
453
+ ) == PackageManager.PERMISSION_GRANTED
454
+ require(granted) { "Microphone permission is required." }
455
+ }
456
+
457
+ private fun buildRecorderConfig(): RecorderConfig {
458
+ val sampleRates = listOf(16_000, 48_000, 44_100)
459
+ val audioSources = listOf(
460
+ MediaRecorder.AudioSource.VOICE_RECOGNITION,
461
+ MediaRecorder.AudioSource.MIC,
462
+ )
463
+ var lastError: String? = null
464
+
465
+ for (audioSource in audioSources) {
466
+ for (sampleRate in sampleRates) {
467
+ val minBufferSize = AudioRecord.getMinBufferSize(
468
+ sampleRate,
469
+ AudioFormat.CHANNEL_IN_MONO,
470
+ AudioFormat.ENCODING_PCM_16BIT,
471
+ )
472
+ if (minBufferSize <= 0) {
473
+ lastError = "Unsupported buffer size for ${sampleRate} Hz."
474
+ continue
475
+ }
476
+
477
+ val bufferSize = maxOf(minBufferSize * 2, 4096)
478
+ val record = try {
479
+ AudioRecord(
480
+ audioSource,
481
+ sampleRate,
482
+ AudioFormat.CHANNEL_IN_MONO,
483
+ AudioFormat.ENCODING_PCM_16BIT,
484
+ bufferSize,
485
+ )
486
+ } catch (error: Exception) {
487
+ lastError = error.message
488
+ null
489
+ }
490
+
491
+ if (record?.state == AudioRecord.STATE_INITIALIZED) {
492
+ return RecorderConfig(
493
+ audioRecord = record,
494
+ sampleRate = sampleRate,
495
+ bufferSize = bufferSize,
496
+ )
497
+ }
498
+
499
+ record?.release()
500
+ lastError = "AudioRecord could not initialize for ${sampleRate} Hz."
501
+ }
502
+ }
503
+
504
+ throw IllegalStateException(lastError ?: "Unable to initialize microphone capture.")
505
+ }
506
+
507
+ private fun attachAudioEnhancers(audioSessionId: Int) {
508
+ if (NoiseSuppressor.isAvailable()) {
509
+ noiseSuppressor = NoiseSuppressor.create(audioSessionId)?.apply {
510
+ enabled = true
511
+ }
512
+ }
513
+ if (AutomaticGainControl.isAvailable()) {
514
+ automaticGainControl = AutomaticGainControl.create(audioSessionId)?.apply {
515
+ enabled = true
516
+ }
517
+ }
518
+ }
519
+
520
+ private fun bytesToMillis(byteCount: Long, sampleRate: Int): Long {
521
+ val bytesPerSecond = sampleRate.toLong() * CHANNEL_COUNT * BYTES_PER_SAMPLE
522
+ if (bytesPerSecond <= 0L) {
523
+ return 0L
524
+ }
525
+ return (byteCount * 1000L) / bytesPerSecond
526
+ }
527
+
528
+ private fun millisToBytes(durationMs: Long, sampleRate: Int): Long {
529
+ return (durationMs * sampleRate.toLong() * CHANNEL_COUNT * BYTES_PER_SAMPLE) / 1000L
530
+ }
531
+
532
+ companion object {
533
+ private const val TAG = "RecordingForeground"
534
+ private const val ACTION_START = "neoagent.recordings.START"
535
+ private const val ACTION_RESTORE = "neoagent.recordings.RESTORE"
536
+ private const val ACTION_PAUSE = "neoagent.recordings.PAUSE"
537
+ private const val ACTION_RESUME = "neoagent.recordings.RESUME"
538
+ private const val ACTION_STOP = "neoagent.recordings.STOP"
539
+ private const val EXTRA_BACKEND_URL = "backend_url"
540
+ private const val EXTRA_SESSION_COOKIE = "session_cookie"
541
+ private const val EXTRA_SESSION_ID = "session_id"
542
+ private const val CHANNEL_ID = "neoagent_recordings"
543
+ private const val NOTIFICATION_ID = 4021
544
+ private const val CHANNEL_COUNT = 1
545
+ private const val BYTES_PER_SAMPLE = 2
546
+ private const val CHUNK_DURATION_MS = 4_000L
547
+
548
+ fun buildStartIntent(
549
+ context: Context,
550
+ backendUrl: String,
551
+ sessionCookie: String,
552
+ sessionId: String,
553
+ ): Intent = Intent(context, RecordingForegroundService::class.java).apply {
554
+ action = ACTION_START
555
+ putExtra(EXTRA_BACKEND_URL, backendUrl)
556
+ putExtra(EXTRA_SESSION_COOKIE, sessionCookie)
557
+ putExtra(EXTRA_SESSION_ID, sessionId)
558
+ }
559
+
560
+ fun buildRestoreIntent(context: Context): Intent =
561
+ Intent(context, RecordingForegroundService::class.java).apply {
562
+ action = ACTION_RESTORE
563
+ }
564
+
565
+ fun buildPauseIntent(context: Context): Intent =
566
+ Intent(context, RecordingForegroundService::class.java).apply {
567
+ action = ACTION_PAUSE
568
+ }
569
+
570
+ fun buildResumeIntent(context: Context): Intent =
571
+ Intent(context, RecordingForegroundService::class.java).apply {
572
+ action = ACTION_RESUME
573
+ }
574
+
575
+ fun buildStopIntent(context: Context): Intent =
576
+ Intent(context, RecordingForegroundService::class.java).apply {
577
+ action = ACTION_STOP
578
+ }
579
+ }
580
+ }
581
+
582
+ private data class RecorderConfig(
583
+ val audioRecord: AudioRecord,
584
+ val sampleRate: Int,
585
+ val bufferSize: Int,
586
+ )
@@ -0,0 +1,78 @@
1
+ package com.neoagent.flutter_app.recording
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+
6
+ class RecordingStateStore(context: Context) {
7
+ private val prefs: SharedPreferences =
8
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
9
+
10
+ fun saveConfig(config: RecordingConfig) {
11
+ prefs.edit()
12
+ .putString(KEY_BACKEND_URL, config.backendUrl)
13
+ .putString(KEY_SESSION_COOKIE, config.sessionCookie)
14
+ .putString(KEY_SESSION_ID, config.sessionId)
15
+ .putBoolean(KEY_ACTIVE, config.active)
16
+ .putBoolean(KEY_PAUSED, config.paused)
17
+ .putInt(KEY_NEXT_SEQUENCE, config.nextSequence)
18
+ .putLong(KEY_CAPTURED_AUDIO_MS, config.capturedAudioMs)
19
+ .putString(KEY_STARTED_AT, config.startedAt)
20
+ .putString(KEY_ERROR_MESSAGE, config.errorMessage)
21
+ .apply()
22
+ }
23
+
24
+ fun loadConfig(): RecordingConfig? {
25
+ val sessionId = prefs.getString(KEY_SESSION_ID, null) ?: return null
26
+ return RecordingConfig(
27
+ backendUrl = prefs.getString(KEY_BACKEND_URL, "").orEmpty(),
28
+ sessionCookie = prefs.getString(KEY_SESSION_COOKIE, "").orEmpty(),
29
+ sessionId = sessionId,
30
+ active = prefs.getBoolean(KEY_ACTIVE, false),
31
+ paused = prefs.getBoolean(KEY_PAUSED, false),
32
+ nextSequence = prefs.getInt(KEY_NEXT_SEQUENCE, 0),
33
+ capturedAudioMs = prefs.getLong(KEY_CAPTURED_AUDIO_MS, 0L),
34
+ startedAt = prefs.getString(KEY_STARTED_AT, null),
35
+ errorMessage = prefs.getString(KEY_ERROR_MESSAGE, null),
36
+ )
37
+ }
38
+
39
+ fun clear() {
40
+ prefs.edit().clear().apply()
41
+ }
42
+
43
+ fun statusMap(): Map<String, Any?> {
44
+ val config = loadConfig()
45
+ return mapOf(
46
+ "active" to (config?.active == true),
47
+ "paused" to (config?.paused == true),
48
+ "sessionId" to config?.sessionId,
49
+ "startedAt" to config?.startedAt,
50
+ "errorMessage" to config?.errorMessage,
51
+ )
52
+ }
53
+
54
+ companion object {
55
+ private const val PREFS_NAME = "neoagent_recordings"
56
+ private const val KEY_BACKEND_URL = "backend_url"
57
+ private const val KEY_SESSION_COOKIE = "session_cookie"
58
+ private const val KEY_SESSION_ID = "session_id"
59
+ private const val KEY_ACTIVE = "active"
60
+ private const val KEY_PAUSED = "paused"
61
+ private const val KEY_NEXT_SEQUENCE = "next_sequence"
62
+ private const val KEY_CAPTURED_AUDIO_MS = "captured_audio_ms"
63
+ private const val KEY_STARTED_AT = "started_at"
64
+ private const val KEY_ERROR_MESSAGE = "error_message"
65
+ }
66
+ }
67
+
68
+ data class RecordingConfig(
69
+ val backendUrl: String,
70
+ val sessionCookie: String,
71
+ val sessionId: String,
72
+ val active: Boolean,
73
+ val paused: Boolean,
74
+ val nextSequence: Int,
75
+ val capturedAudioMs: Long,
76
+ val startedAt: String?,
77
+ val errorMessage: String?,
78
+ )