neoagent 3.1.1-beta.9 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.env.example +2 -2
  2. package/README.md +5 -1
  3. package/docs/architecture.md +1 -1
  4. package/docs/benchmarking.md +68 -87
  5. package/docs/capabilities.md +1 -1
  6. package/docs/clients-and-devices.md +3 -4
  7. package/docs/configuration.md +1 -1
  8. package/docs/devices.md +1 -2
  9. package/docs/health.md +24 -0
  10. package/docs/index.md +2 -2
  11. package/docs/integrations-architecture.md +6 -0
  12. package/docs/integrations.md +15 -1
  13. package/docs/models.md +1 -1
  14. package/docs/persistence.md +1 -1
  15. package/docs/why-neoagent.md +1 -1
  16. package/flutter_app/README.md +0 -5
  17. package/flutter_app/android/app/src/main/AndroidManifest.xml +0 -14
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +0 -98
  19. package/flutter_app/lib/main.dart +0 -6
  20. package/flutter_app/lib/main_app_shell.dart +0 -2
  21. package/flutter_app/lib/main_controller.dart +2 -867
  22. package/flutter_app/lib/main_devices.dart +0 -73
  23. package/flutter_app/lib/main_integrations.dart +339 -187
  24. package/flutter_app/lib/main_launcher.dart +2 -212
  25. package/flutter_app/lib/main_models.dart +0 -259
  26. package/flutter_app/lib/main_navigation.dart +2 -14
  27. package/flutter_app/lib/main_operations.dart +322 -364
  28. package/flutter_app/lib/main_runtime.dart +9 -208
  29. package/flutter_app/lib/main_settings.dart +17 -199
  30. package/flutter_app/lib/main_shared.dart +20 -591
  31. package/flutter_app/lib/main_timeline.dart +26 -122
  32. package/flutter_app/lib/main_voice_assistant.dart +0 -5
  33. package/flutter_app/lib/src/backend_client.dart +0 -102
  34. package/flutter_app/lib/src/desktop_companion_io.dart +1 -65
  35. package/flutter_app/lib/src/desktop_companion_stub.dart +0 -12
  36. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +0 -4
  37. package/flutter_app/linux/flutter/generated_plugins.cmake +0 -1
  38. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
  39. package/flutter_app/macos/Podfile.lock +56 -5
  40. package/flutter_app/pubspec.lock +0 -7
  41. package/flutter_app/pubspec.yaml +0 -2
  42. package/flutter_app/web/index.html +174 -0
  43. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +0 -3
  44. package/flutter_app/windows/flutter/generated_plugins.cmake +0 -1
  45. package/landing/index.html +0 -2
  46. package/lib/manager.js +1 -1
  47. package/lib/schema_migrations.js +75 -0
  48. package/package.json +1 -4
  49. package/server/db/database.js +1 -133
  50. package/server/http/middleware.js +2 -16
  51. package/server/http/routes.js +0 -2
  52. package/server/public/.last_build_id +1 -1
  53. package/server/public/assets/NOTICES +0 -25
  54. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  55. package/server/public/flutter_bootstrap.js +1 -1
  56. package/server/public/index.html +174 -0
  57. package/server/public/main.dart.js +88081 -90036
  58. package/server/routes/auth.js +11 -2
  59. package/server/routes/settings.js +0 -5
  60. package/server/routes/voice_assistant.js +1 -128
  61. package/server/services/account/erasure.js +0 -3
  62. package/server/services/ai/deliverables/artifact_helpers.js +1 -1
  63. package/server/services/ai/loop/conversation_loop.js +6 -2
  64. package/server/services/ai/loop/tool_dispatch.js +0 -3
  65. package/server/services/ai/messagingFallback.js +1 -1
  66. package/server/services/ai/settings.js +0 -6
  67. package/server/services/ai/toolEvidence.js +1 -4
  68. package/server/services/ai/tools.js +0 -234
  69. package/server/services/desktop/auth.js +0 -3
  70. package/server/services/desktop/registry.js +2 -50
  71. package/server/services/integrations/neoarchive/provider.js +616 -0
  72. package/server/services/integrations/neorecall/client.js +140 -0
  73. package/server/services/integrations/neorecall/constants.js +87 -0
  74. package/server/services/integrations/neorecall/provider.js +163 -0
  75. package/server/services/integrations/registry.js +4 -2
  76. package/server/services/manager.js +0 -22
  77. package/server/services/memory/manager.js +44 -19
  78. package/server/services/memory/retrieval_reasoning.js +36 -14
  79. package/server/services/messaging/whatsapp.js +18 -0
  80. package/server/services/security/tool_categories.js +0 -5
  81. package/server/services/social_video/service.js +2 -20
  82. package/server/services/tasks/adapters/index.js +1 -1
  83. package/server/services/tasks/integration_runtime.js +3 -67
  84. package/server/services/tasks/runtime.js +1 -6
  85. package/server/services/tasks/task_repository.js +6 -13
  86. package/server/services/timeline/service.js +0 -224
  87. package/server/services/voice/message.js +0 -24
  88. package/server/services/voice/providers.js +42 -1
  89. package/server/services/voice/runtimeManager.js +9 -5
  90. package/server/services/voice/turnRunner.js +0 -22
  91. package/server/services/wearable/service.js +0 -5
  92. package/docs/recordings-and-health.md +0 -41
  93. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +0 -21
  94. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +0 -586
  95. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +0 -78
  96. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +0 -104
  97. package/flutter_app/lib/main_recordings.dart +0 -920
  98. package/flutter_app/lib/src/desktop_ocr_bridge.dart +0 -2
  99. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +0 -125
  100. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +0 -30
  101. package/flutter_app/lib/src/desktop_passive_history.dart +0 -332
  102. package/flutter_app/lib/src/recording_bridge.dart +0 -232
  103. package/flutter_app/lib/src/recording_bridge_io.dart +0 -1027
  104. package/flutter_app/lib/src/recording_bridge_stub.dart +0 -120
  105. package/flutter_app/lib/src/recording_bridge_web.dart +0 -702
  106. package/flutter_app/lib/src/recording_chunk_queue.dart +0 -149
  107. package/flutter_app/lib/src/recording_chunk_queue_io.dart +0 -182
  108. package/flutter_app/lib/src/recording_payloads.dart +0 -95
  109. package/flutter_app/third_party/desktop_audio_capture/LICENSE +0 -21
  110. package/flutter_app/third_party/desktop_audio_capture/README.md +0 -262
  111. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +0 -65
  112. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +0 -153
  113. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +0 -110
  114. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +0 -461
  115. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +0 -91
  116. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +0 -106
  117. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +0 -219
  118. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +0 -336
  119. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +0 -101
  120. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +0 -692
  121. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +0 -35
  122. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +0 -36
  123. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +0 -32
  124. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +0 -32
  125. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +0 -878
  126. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +0 -27
  127. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +0 -1172
  128. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +0 -655
  129. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +0 -12
  130. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +0 -30
  131. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +0 -87
  132. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +0 -105
  133. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +0 -80
  134. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +0 -31
  135. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +0 -12
  136. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +0 -23
  137. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +0 -25
  138. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +0 -1117
  139. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +0 -115
  140. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +0 -777
  141. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +0 -87
  142. package/server/routes/recordings.js +0 -335
  143. package/server/routes/screenHistory.js +0 -190
  144. package/server/services/ai/recordingInsights.js +0 -105
  145. package/server/services/integrations/neomail/provider.js +0 -993
  146. package/server/services/recordings/deepgram.js +0 -58
  147. package/server/services/recordings/manager.js +0 -1273
  148. package/server/services/tasks/adapters/neomail_email_received.js +0 -46
  149. package/server/services/voice/screenshotContext.js +0 -73
  150. package/server/services/voice/turnCoordinator.js +0 -51
@@ -1,41 +0,0 @@
1
- # Recordings and health
2
-
3
- Recordings and health data are optional features of the NeoAgent clients. Both
4
- store their processed data on the NeoAgent backend.
5
-
6
- ## Recordings
7
-
8
- The web client can capture microphone and screen audio. The Android client can
9
- record microphone audio through a foreground service.
10
-
11
- Recordings upload in chunks and move through recording, processing, completed,
12
- failed, or cancelled states. When Deepgram is configured, NeoAgent transcribes
13
- the audio and stores timestamped segments that can be searched from the
14
- operator interface or by the agent.
15
-
16
- Optional recording insights can produce summaries, action items, and event
17
- suggestions after transcription.
18
-
19
- The agent can list recordings, inspect a session, and search transcripts. It
20
- can also extract transcripts and metadata from supported public social-video
21
- URLs when the required host tool is installed.
22
-
23
- ## Health Connect
24
-
25
- The Android app can read Health Connect records after the user grants Android
26
- permissions. Supported data includes steps, heart rate, sleep, exercise, and
27
- weight.
28
-
29
- The app uploads granted records to the user's NeoAgent backend. Stored metrics
30
- appear in **Health** and are available to the owning agent through the health
31
- tool.
32
-
33
- Health Connect access can be revoked in Android settings. Revoking access stops
34
- future reads but does not remove records already uploaded to NeoAgent; delete
35
- stored server data separately when required.
36
-
37
- ## Privacy
38
-
39
- Audio, transcripts, and health records are sensitive personal data. Protect the
40
- server account, use HTTPS for remote clients, restrict agents and integrations,
41
- and include these data stores in backup and deletion procedures.
@@ -1,21 +0,0 @@
1
- package com.neoagent.flutter_app.recording
2
-
3
- import android.content.BroadcastReceiver
4
- import android.content.Context
5
- import android.content.Intent
6
- import androidx.core.content.ContextCompat
7
-
8
- class RecordingBootReceiver : BroadcastReceiver() {
9
- override fun onReceive(context: Context, intent: Intent?) {
10
- if (intent?.action != Intent.ACTION_BOOT_COMPLETED) {
11
- return
12
- }
13
- val store = RecordingStateStore(context)
14
- val config = store.loadConfig() ?: return
15
- if (!config.active || config.paused) {
16
- return
17
- }
18
- val serviceIntent = RecordingForegroundService.buildRestoreIntent(context)
19
- ContextCompat.startForegroundService(context, serviceIntent)
20
- }
21
- }
@@ -1,586 +0,0 @@
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
- )