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.
- package/.env.example +39 -0
- package/README.md +2 -0
- package/docs/capabilities.md +2 -2
- package/docs/configuration.md +13 -5
- package/docs/integrations.md +4 -1
- package/flutter_app/.metadata +42 -0
- package/flutter_app/README.md +21 -0
- package/flutter_app/analysis_options.yaml +32 -0
- package/flutter_app/android/app/build.gradle.kts +109 -0
- package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
- package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
- package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
- package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
- package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
- package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
- package/flutter_app/android/build.gradle.kts +24 -0
- package/flutter_app/android/ci-release.keystore +0 -0
- package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
- package/flutter_app/android/gradle.properties +3 -0
- package/flutter_app/android/key.properties +4 -0
- package/flutter_app/android/settings.gradle.kts +26 -0
- package/flutter_app/assets/branding/app_icon_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_64.png +0 -0
- package/flutter_app/assets/branding/tray_icon_template.png +0 -0
- package/flutter_app/lib/features/location/location_service.dart +119 -0
- package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
- package/flutter_app/lib/main.dart +23057 -0
- package/flutter_app/lib/main_app_shell.dart +1682 -0
- package/flutter_app/lib/main_integrations.dart +931 -0
- package/flutter_app/lib/main_launcher.dart +959 -0
- package/flutter_app/lib/main_launcher_entry.dart +5 -0
- package/flutter_app/lib/main_models.dart +3473 -0
- package/flutter_app/lib/main_shared.dart +2861 -0
- package/flutter_app/lib/main_theme.dart +204 -0
- package/flutter_app/lib/main_voice_assistant.dart +831 -0
- package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
- package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
- package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
- package/flutter_app/lib/src/android_app_installer.dart +22 -0
- package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
- package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
- package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
- package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
- package/flutter_app/lib/src/app_release_updater.dart +511 -0
- package/flutter_app/lib/src/backend_client.dart +1833 -0
- package/flutter_app/lib/src/desktop_companion.dart +2 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
- package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
- package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
- package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
- package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
- package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
- package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
- package/flutter_app/lib/src/health_bridge.dart +136 -0
- package/flutter_app/lib/src/live_voice_capture.dart +85 -0
- package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
- package/flutter_app/lib/src/network/app_http_client.dart +53 -0
- package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
- package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
- package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
- package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
- package/flutter_app/lib/src/oauth_launcher.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
- package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
- package/flutter_app/lib/src/recording_bridge.dart +232 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
- package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
- package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
- package/flutter_app/lib/src/recording_payloads.dart +86 -0
- package/flutter_app/lib/src/theme/palette.dart +81 -0
- package/flutter_app/lib/src/widget_bridge.dart +49 -0
- package/flutter_app/linux/CMakeLists.txt +128 -0
- package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
- package/flutter_app/linux/runner/CMakeLists.txt +26 -0
- package/flutter_app/linux/runner/main.cc +6 -0
- package/flutter_app/linux/runner/my_application.cc +144 -0
- package/flutter_app/linux/runner/my_application.h +18 -0
- package/flutter_app/linux/runner/resources/app_icon.png +0 -0
- package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
- package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
- package/flutter_app/macos/Podfile +42 -0
- package/flutter_app/macos/Podfile.lock +87 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
- package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
- package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
- package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
- package/flutter_app/macos/Runner/Info.plist +36 -0
- package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
- package/flutter_app/macos/Runner/Release.entitlements +12 -0
- package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
- package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
- package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
- package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
- package/flutter_app/patch_strings.py +12 -0
- package/flutter_app/pubspec.lock +1088 -0
- package/flutter_app/pubspec.yaml +53 -0
- package/flutter_app/test/messaging_access_summary_test.dart +22 -0
- package/flutter_app/test/recording_payloads_test.dart +53 -0
- package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
- package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
- package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
- package/flutter_app/tool/generate_desktop_branding.py +219 -0
- package/flutter_app/web/favicon.png +0 -0
- package/flutter_app/web/favicon.svg +12 -0
- package/flutter_app/web/icons/Icon-192.png +0 -0
- package/flutter_app/web/icons/Icon-512.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
- package/flutter_app/web/index.html +39 -0
- package/flutter_app/web/manifest.json +35 -0
- package/flutter_app/windows/CMakeLists.txt +108 -0
- package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
- package/flutter_app/windows/runner/CMakeLists.txt +41 -0
- package/flutter_app/windows/runner/Runner.rc +121 -0
- package/flutter_app/windows/runner/flutter_window.cpp +533 -0
- package/flutter_app/windows/runner/flutter_window.h +37 -0
- package/flutter_app/windows/runner/main.cpp +53 -0
- package/flutter_app/windows/runner/resource.h +16 -0
- package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
- package/flutter_app/windows/runner/runner.exe.manifest +14 -0
- package/flutter_app/windows/runner/utils.cpp +65 -0
- package/flutter_app/windows/runner/utils.h +19 -0
- package/flutter_app/windows/runner/win32_window.cpp +299 -0
- package/flutter_app/windows/runner/win32_window.h +102 -0
- package/lib/manager.js +231 -7
- package/package.json +3 -1
- package/server/db/database.js +68 -0
- package/server/http/middleware.js +50 -0
- package/server/http/routes.js +3 -1
- package/server/index.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +61 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +65262 -64422
- package/server/routes/integrations.js +86 -0
- package/server/routes/memory.js +11 -2
- package/server/routes/screenHistory.js +46 -0
- package/server/routes/triggers.js +81 -0
- package/server/services/ai/models.js +30 -0
- package/server/services/ai/providers/githubCopilot.js +97 -0
- package/server/services/ai/providers/openai.js +2 -1
- package/server/services/ai/providers/openaiCodex.js +31 -0
- package/server/services/ai/settings.js +20 -0
- package/server/services/ai/systemPrompt.js +1 -1
- package/server/services/ai/tools.js +35 -6
- package/server/services/browser/controller.js +47 -3
- package/server/services/desktop/screenRecorder.js +172 -0
- package/server/services/integrations/env.js +5 -0
- package/server/services/integrations/github/common.js +106 -0
- package/server/services/integrations/github/provider.js +499 -0
- package/server/services/integrations/github/repos.js +1124 -0
- package/server/services/integrations/home_assistant/provider.js +306 -26
- package/server/services/integrations/manager.js +63 -7
- package/server/services/integrations/oauth_provider.js +13 -6
- package/server/services/integrations/provider_config_store.js +76 -0
- package/server/services/integrations/registry.js +4 -0
- package/server/services/integrations/trello/provider.js +744 -0
- package/server/services/integrations/whatsapp/provider.js +6 -2
- package/server/services/manager.js +22 -0
- package/server/services/memory/manager.js +39 -2
- package/server/services/skills/base_catalog.js +33 -0
- package/server/services/tasks/adapters/index.js +1 -0
- package/server/services/tasks/adapters/manual.js +12 -0
- package/server/services/tasks/runtime.js +1 -1
- package/server/services/voice/openaiClient.js +4 -1
- package/server/services/voice/providers.js +2 -1
- package/server/services/widgets/service.js +49 -4
- package/server/utils/local_secrets.js +56 -0
- 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
|
+
)
|