From ae9003decdaf1aa446476ea1f504f2f4a6ba7f90 Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:43:52 -0400 Subject: [PATCH] feat(#1247): hold-to-record voice notes with server-side transcription (#1250) Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com> --- .../chat/components/ComposerReadinessTest.kt | 8 + .../data/model/AudioTranscription.kt | 30 ++ .../hermescontrol/data/remote/ApiClient.kt | 84 +++- .../data/remote/HermesApiService.kt | 12 + .../data/remote/ProfileScopeInterceptor.kt | 7 + .../data/repository/VoiceNoteRepository.kt | 98 ++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 13 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 126 +++++ .../ui/chat/VoiceNoteRecorder.kt | 120 +++++ .../ui/chat/components/ChatComposer.kt | 114 +++-- .../ui/chat/components/ChatMediaLaunchers.kt | 135 +++++- .../ui/chat/components/ComposerToolbar.kt | 209 ++++++++- .../components/VoiceNoteRecordingPanel.kt | 128 ++++++ app/src/main/res/values/strings.xml | 3 + .../remote/ProfileScopeInterceptorTest.kt | 26 ++ .../repository/VoiceNoteRepositoryTest.kt | 133 ++++++ .../ui/chat/ChatUpdateCommandTest.kt | 9 + .../ui/chat/ChatViewModelTest.kt | 434 ++++++++++++++++++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 19 +- 19 files changed, 1619 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt index 796c9a5e..fc075cf0 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt @@ -34,6 +34,7 @@ class ComposerReadinessTest { onMicTap = {}, isListening = false, isAgentTyping = true, + canInterrupt = ready.value, isConnected = true, commandCatalog = CommandCatalog(), isSessionReady = ready.value, @@ -42,13 +43,20 @@ class ComposerReadinessTest { compose.onNodeWithTag("chat_input").performTextInput("held draft") compose.onNodeWithTag("chat_input").assertTextEquals("held draft") compose.onNodeWithTag("chat_session_preparing").assertExists() + // Nothing to interrupt while the session prepares: the action button + // must not offer Stop (review, PR #1250). + compose.onNodeWithTag("stop_button").assertDoesNotExist() compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick() compose.runOnIdle { assertEquals(0, sends) } compose.runOnIdle { ready.value = true } compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist() + // Ready + typing = an interruptible generation: Stop replaces the + // action glyph while queue-send stays available in the flat slot. + compose.onNodeWithTag("stop_button").assertExists() compose.onNodeWithTag("send_button").assertIsEnabled().performClick() compose.runOnIdle { assertEquals(1, sends) } compose.runOnIdle { ready.value = false } + compose.onNodeWithTag("stop_button").assertDoesNotExist() compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("chat_input").assertTextEquals("held draft") } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt b/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt new file mode 100644 index 00000000..a260b2d0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Request body for `POST /api/audio/transcribe` — the dashboard's server-side + * transcription relay, the same endpoint the desktop client's voice path uses. + * + * The recording rides inline as a base64 `data:` URL (the desktop wire shape). + * [OkHttpProvider.json] carries [kotlinx.serialization.json.JsonNamingStrategy.SnakeCase], + * so [dataUrl]/[mimeType] serialize as `data_url`/`mime_type`. + */ +@Serializable +data class AudioTranscriptionRequest( + val dataUrl: String, + val mimeType: String, +) + +/** + * Response of `POST /api/audio/transcribe`. + * + * [transcript] is empty (not an error) when the server detects no speech in + * the recording. + */ +@Serializable +data class AudioTranscriptionResponse( + val ok: Boolean = false, + val transcript: String? = null, + val provider: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index e413181b..369d9a43 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.data.local.AuthManager import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit /** * Provides a Retrofit-backed [HermesApiService]. @@ -22,6 +24,9 @@ object ApiClient { @Volatile private var service: HermesApiService? = null + @Volatile + private var okHttpClient: OkHttpClient? = null + /** The current [HermesApiService] instance. Lazily created on first access. */ val hermesApi: HermesApiService get() { @@ -39,6 +44,7 @@ object ApiClient { synchronized(this) { retrofit = null service = null + okHttpClient = null } } @@ -81,6 +87,64 @@ object ApiClient { return tempRetrofit.create(HermesApiService::class.java) } + /** + * Dedicated [HermesApiService] for the blocking voice-note transcription + * POST (`/api/audio/transcribe`). + * + * STT blocks until the profile's provider finishes, so it cannot run on + * the shared 30s client, and a re-submitted POST would repeat expensive + * provider work after the request may already have reached the server + * (the repository also passes `retries = 0`). The returned service reuses + * the shared connection pool and the auth/profile interceptor stack, and + * applies [readTimeoutMs] to reads and writes with transport retries + * disabled (review, PR #1250). + */ + fun transcriptionService(readTimeoutMs: Long): HermesApiService { + val client = + OkHttpProvider + .base + .newBuilder() + .addInterceptor(restAuthInterceptor()) + .addInterceptor(ProfileScopeInterceptor) + .authenticator(TokenRefreshAuthenticator) + .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) + .retryOnConnectionFailure(false) + .build() + return Retrofit + .Builder() + .baseUrl(AuthManager.endpointForBuild().baseUrl) + .client(client) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .create(HermesApiService::class.java) + } + + /** + * REST auth stamping shared by every client: token mode sends the bearer + * header; gated (basic-auth cookie) mode leaves the request alone because + * the shared CookieManager jar carries the session cookie and a stray + * Authorization header would 401 (issue #470). + */ + private fun restAuthInterceptor(): Interceptor = + Interceptor { chain -> + val request = chain.request() + if (AuthManager.isGatedMode()) { + return@Interceptor chain.proceed(request) + } + val token = AuthManager.getToken() + if (!token.isNullOrBlank()) { + chain.proceed( + request + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .build(), + ) + } else { + chain.proceed(request) + } + } + // ── Internal ───────────────────────────────────────────────────────── private fun buildService(): HermesApiService = @@ -111,25 +175,7 @@ object ApiClient { // must NOT stamp one here — otherwise every REST tab (skills, cron, // config, ...) fails with "token expired" while the WS chat, which // authenticates via ?ticket=, keeps working. - val authInterceptor = - Interceptor { chain -> - val request = chain.request() - if (AuthManager.isGatedMode()) { - // Cookie in the shared jar is the only valid REST credential. - return@Interceptor chain.proceed(request) - } - val token = AuthManager.getToken() - if (!token.isNullOrBlank()) { - chain.proceed( - request - .newBuilder() - .addHeader("Authorization", "Bearer $token") - .build(), - ) - } else { - chain.proceed(request) - } - } + val authInterceptor = restAuthInterceptor() val okHttp = OkHttpProvider diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index bbaf3811..55e6cddf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AddMcpServerRequest import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AnalyticsResponse +import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.BulkDeleteRequest @@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService { suspend fun deleteManagedFile( @Body body: ManagedFileDelete, ): Response + + // ── Audio: server-side transcription ──────────────────────────────── + // The dashboard's voice relay (hermes_cli/web_routers/audio.py) — the + // same endpoint the desktop client's voice notes use. The clip rides + // inline as a base64 data URL; the server resolves STT through the + // active profile's configured provider. + @POST("api/audio/transcribe") + suspend fun transcribeAudio( + @Body body: AudioTranscriptionRequest, + ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 497a5a63..3c407a83 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor { private val PROFILE_SCOPED_PREFIXES = listOf( "/api/analytics", + // Voice-note transcription resolves STT through the profile's + // configured provider, so the request must carry the active + // profile (the desktop sends this route `...profileScoped()`; + // hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a + // multi-profile host transcribes under the launch profile + // (review, PR #1250). + "/api/audio", "/api/config", "/api/credentials/pool", "/api/cron", diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt new file mode 100644 index 00000000..3dabae8a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.remote.NetworkError +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import java.io.File +import java.io.IOException +import java.util.Base64 + +/** + * MIME type of the recorder's output: MPEG-4 container with AAC audio — see + * `VoiceNoteRecorder` in the chat UI layer. + */ +const val VOICE_NOTE_MIME_TYPE = "audio/mp4" + +/** + * Transcribes a recorded voice note through the dashboard's server-side + * transcription endpoint (`POST /api/audio/transcribe`). + * + * The wire shape is the desktop client's: base64 `data:` URL plus MIME type. + * The server resolves STT through the active profile's configured provider + * (the request is profile-scoped by ProfileScopeInterceptor, matching the + * desktop's `...profileScoped()` on this route), so the transcript matches + * the rest of Hermes instead of the phone's on-device recognizer. + * + * Two contracts differ from a plain REST call (review, PR #1250): + * - [serviceProvider] resolves the Retrofit service at CALL time, so a + * connection/profile switch (ApiClient.rebuild) applies to the next + * transcription instead of pinning the construction-time service. + * - The STT POST runs on a dedicated long-timeout client with `retries = 0`: + * re-submitting after a timeout or 5xx would repeat expensive provider + * work although the request may already have reached the server. + * + * Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern). + */ +open class VoiceNoteRepository( + private val serviceProvider: (timeoutMs: Long) -> HermesApiService = { timeoutMs -> + ApiClient.transcriptionService(timeoutMs) + }, +) { + open suspend fun transcribe(file: File): NetworkResult { + val encoded = + try { + file.readBytes() + } catch (e: IOException) { + // A local read failure must return a Failure instead of + // escaping as an exception — the caller resets its + // transcription state from this result (review, PR #1250). + return NetworkResult.Failure( + NetworkError.Unknown("Failed to read the voice note: ${e.message}", e), + ) + } + val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded) + val result = + safeApiCall(retries = 0) { + serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio( + AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), + ) + } + return when (result) { + is NetworkResult.Success -> { + NetworkResult.Success( + result.data + ?.transcript + .orEmpty() + .trim(), + ) + } + + is NetworkResult.Failure -> { + result + } + } + } + + companion object { + /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */ + const val MIN_REQUEST_TIMEOUT_MS = 180_000L + + /** Cap so a hung transcription cannot pin the call forever — desktop parity. */ + const val MAX_REQUEST_TIMEOUT_MS = 600_000L + + private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L + + /** + * Timeout for the blocking STT endpoint, mirroring the desktop's + * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char + * keeps short clips at the floor while multi-minute recordings scale + * toward the cap. Integer ceil() keeps the arithmetic exact. + */ + fun transcriptionTimeoutMs(dataUrl: String): Long = + ((dataUrl.length + BASE64_CHARS_PER_TIMEOUT_MS - 1) / BASE64_CHARS_PER_TIMEOUT_MS) + .coerceIn(MIN_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 3dec359f..ee9d91da 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -440,12 +440,16 @@ fun ChatScreen( onAddAttachments = { attachments -> viewModel.addAttachments(attachments) }, + onVoiceNoteRecorded = { file -> + viewModel.sendVoiceNote(file) + }, onShowMessage = { msg -> scrollScope.launch { snackbarHostState.showSnackbar(msg) } }, launchExternalActivity = launchExternalActivity, + isTranscribingVoiceNote = state.isTranscribingVoiceNote, context = context, ) @@ -901,9 +905,16 @@ fun ChatScreen( } }, onMicTap = mediaLaunchers.onMicTap, - isListening = mediaLaunchers.isListening, + onMicHoldStart = mediaLaunchers.onMicHoldStart, + onMicHoldEnd = mediaLaunchers.onMicHoldEnd, + onMicHoldCancel = mediaLaunchers.onMicHoldCancel, + isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, + isRecordingVoice = mediaLaunchers.isRecordingVoice, + voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude, + onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, isMainTurnBusy = state.isMainTurnBusy, + canInterrupt = state.canInterrupt, isConnected = state.isConnected, isSessionReady = state.isSessionReady && !timelineState.isHistorical, sessionPreparationFailed = state.resumeError != null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 5d6ddc93..12832256 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.SlashUsageStore import com.m57.hermescontrol.data.model.Attachment @@ -22,6 +23,7 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.repository.VoiceNoteRepository import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.CommandBlocklist @@ -66,6 +68,11 @@ private const val TAG = "ChatViewModel" private const val MESSAGE_PAGE_SIZE = 150 private const val TIMELINE_PAGE_SIZE = 500 private const val HISTORY_WINDOW_SIZE = 120 +private const val VOICE_NOTE_OFFLINE_MESSAGE = "Voice note not sent — not connected" +private const val VOICE_NOTE_EMPTY_MESSAGE = "No speech detected in the voice note" +private const val VOICE_NOTE_FAILED_MESSAGE = "Voice note transcription failed" +private const val VOICE_NOTE_UNSENT_MESSAGE = + "Voice note not sent — transcript kept in the input field" private val REASONING_EFFORT_LEVELS = setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") @@ -135,6 +142,8 @@ data class ChatUiState( val pendingSends: List = emptyList(), val isThinking: Boolean = false, val thinkingText: String = "", + /** True while a recorded voice note uploads for server-side transcription. */ + val isTranscribingVoiceNote: Boolean = false, val isLoading: Boolean = false, val isLoadingOlder: Boolean = false, val hasOlderMessages: Boolean = false, @@ -255,6 +264,15 @@ data class ChatUiState( ) { /** Convenience — derived from [connectionStatus]. */ val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED + + /** + * True only while a generation can actually be interrupted: typing can + * start during session preparation, before any runtime session exists, + * and `session.interrupt` has nothing to stop then. The composer derives + * its Stop affordance from this instead of [isAgentTyping] alone + * (review, PR #1250). + */ + val canInterrupt: Boolean get() = isAgentTyping && isSessionReady } data class ChatTimelineState( @@ -414,6 +432,7 @@ class ChatViewModel( private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher, private val sendStore: ChatSendStore = ChatSendStore(application), + private val voiceNoteRepository: VoiceNoteRepository = VoiceNoteRepository(), ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) @@ -2874,6 +2893,113 @@ class ChatViewModel( return true } + /** True while a voice-note transcription owns the mic-to-send pipeline. */ + private var voiceNoteTranscriptionInFlight = false + + /** + * Transcribe a recorded voice note through the dashboard's server-side + * STT relay (`POST /api/audio/transcribe` — the desktop client's voice + * path) and submit the transcript as the next message. The profile's + * configured STT provider answers, not the phone's on-device recognizer. + * + * The recording belongs to the session that was active when it was taken: + * transcription takes long enough for the socket, the profile, or the user + * to move on, so a transcript that can no longer land in that session or + * connection/profile scope is preserved in the composer instead of being + * dropped. Only one + * transcription runs at a time — [voiceNoteTranscriptionInFlight] keeps + * every branch below single-flight (review, PR #1250). + */ + fun sendVoiceNote(file: File) { + if (voiceNoteTranscriptionInFlight) { + file.delete() + return + } + if (!canSubmitMessage()) { + file.delete() + _uiState.update { it.copy(errorMessage = VOICE_NOTE_OFFLINE_MESSAGE) } + return + } + val recordedSessionId = _uiState.value.currentSessionId + val recordedRuntimeSessionId = runtimeSessionId + // The recording also belongs to the connection/profile scope it was + // taken on: both session IDs can still be null/null across a scope + // switch while a replacement session create is pending, so the data + // scope is the deciding identity for that window (review, PR #1250). + val recordedDataScope = AuthManager.currentDataScope() + voiceNoteTranscriptionInFlight = true + viewModelScope.launch { + _uiState.update { it.copy(isTranscribingVoiceNote = true) } + try { + val result = + withContext(ioDispatcher) { + try { + voiceNoteRepository.transcribe(file) + } finally { + file.delete() + } + } + when (result) { + is NetworkResult.Success -> { + val transcript = result.data.trim() + if (transcript.isEmpty()) { + _uiState.update { it.copy(errorMessage = VOICE_NOTE_EMPTY_MESSAGE) } + } else { + deliverVoiceTranscript( + transcript = transcript, + recordedSessionId = recordedSessionId, + recordedRuntimeSessionId = recordedRuntimeSessionId, + recordedDataScope = recordedDataScope, + ) + } + } + + is NetworkResult.Failure -> { + Log.w(TAG, "Voice note transcription failed: ${result.error.message}") + _uiState.update { + it.copy(errorMessage = "$VOICE_NOTE_FAILED_MESSAGE: ${result.error.message}") + } + } + } + } finally { + // Always release the single-flight flag and the composer's + // transcription state, even if a branch above throws. + voiceNoteTranscriptionInFlight = false + _uiState.update { it.copy(isTranscribingVoiceNote = false) } + } + } + } + + /** + * Submits a transcribed voice note, or keeps the transcript in the input + * field when it can no longer land where it was recorded. The recording + * file is already deleted by now, so a rejected send restores the text + * instead of losing it. + */ + private fun deliverVoiceTranscript( + transcript: String, + recordedSessionId: String?, + recordedRuntimeSessionId: String?, + recordedDataScope: DataScope?, + ) { + val sessionChanged = + _uiState.value.currentSessionId != recordedSessionId || + runtimeSessionId != recordedRuntimeSessionId + // A connection/profile switch can leave the session IDs null on both + // sides of the recording (a replacement session create may still be + // pending), so the data scope is the ownership check that catches + // that move (review, PR #1250). + val scopeChanged = AuthManager.currentDataScope() != recordedDataScope + if (sessionChanged || scopeChanged || !sendMessage(transcript)) { + _uiState.update { + it.copy( + composerTextToRestore = transcript, + errorMessage = VOICE_NOTE_UNSENT_MESSAGE, + ) + } + } + } + private fun canSubmitMessage(): Boolean = _uiState.value.pendingReasoningLevel == null && wsClient.connectionStatus.value == ConnectionStatus.CONNECTED && diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt new file mode 100644 index 00000000..66e4b5e6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt @@ -0,0 +1,120 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.SystemClock +import android.util.Log +import java.io.File + +/** + * Records a hold-to-talk voice note as an MPEG-4/AAC file in the app cache. + * + * One instance drives one in-flight recording; the call site owns UI state and + * decides what to do with the clip. 16 kHz mono AAC keeps the payload small + * (roughly 0.5 MB at [MAX_DURATION_MS]) so it uploads quickly to the + * dashboard's transcription endpoint. + */ +class VoiceNoteRecorder( + private val context: Context, +) { + companion object { + private const val TAG = "VoiceNoteRecorder" + + /** Clips shorter than this are treated as accidental and discarded. */ + const val MIN_DURATION_MS = 500L + + /** + * Hard cap. The call site (ChatMediaLaunchers) owns the timer and + * drives the single stop path — the recorder itself no longer stops + * recording, so MediaRecorder's auto-stop cannot race the + * hold-release stop (review, PR #1250). + */ + const val MAX_DURATION_MS = 120_000 + } + + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private var startedAtMs = 0L + + val isActive: Boolean + get() = recorder != null + + /** Starts recording. Returns false when the microphone is unavailable. */ + fun start(): Boolean { + if (recorder != null) return false + val file = + runCatching { File.createTempFile("voice_", ".m4a", context.cacheDir) } + .getOrNull() + ?: return false + return try { + val mediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + mediaRecorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(16_000) + setAudioEncodingBitRate(32_000) + setAudioChannels(1) + setOutputFile(file.absolutePath) + prepare() + start() + } + recorder = mediaRecorder + outputFile = file + startedAtMs = SystemClock.elapsedRealtime() + Log.i(TAG, "Voice note recording started: ${file.name}") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to start voice note recording", e) + releaseQuietly() + file.delete() + false + } + } + + /** + * Stops recording and returns the clip when it is long enough to send. + * Returns null for too-short or failed recordings — the temp file is + * removed in both cases. + */ + fun stop(): File? { + val active = recorder ?: return null + val file = outputFile + val durationMs = SystemClock.elapsedRealtime() - startedAtMs + val stopped = runCatching { active.stop() }.isSuccess + releaseQuietly() + val byteCount = file?.length() ?: -1L + Log.i(TAG, "Voice note stop: durationMs=$durationMs stopped=$stopped bytes=$byteCount") + if (!stopped || file == null || durationMs < MIN_DURATION_MS) { + file?.delete() + return null + } + return file + } + + /** Aborts the recording and deletes the clip. */ + fun cancel() { + if (recorder != null) { + runCatching { recorder?.stop() } + } + releaseQuietly() + outputFile?.delete() + } + + /** Peak amplitude of the current recording window — 0 when idle. */ + fun currentAmplitude(): Int = runCatching { recorder?.maxAmplitude ?: 0 }.getOrDefault(0) + + private fun releaseQuietly() { + runCatching { recorder?.release() } + recorder = null + outputFile = null + startedAtMs = 0L + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 331528d1..ea3651f2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -83,6 +84,7 @@ fun ChatInputBar( isListening: Boolean, isAgentTyping: Boolean, isMainTurnBusy: Boolean = false, + canInterrupt: Boolean = false, isConnected: Boolean, commandCatalog: CommandCatalog, isSessionReady: Boolean, @@ -108,6 +110,12 @@ fun ChatInputBar( showModelProvider: Boolean = false, reasoningWireLevel: String? = null, pendingReasoningLevel: String? = null, + onMicHoldStart: () -> Unit = {}, + onMicHoldEnd: () -> Unit = {}, + onMicHoldCancel: () -> Unit = {}, + isRecordingVoice: Boolean = false, + voiceNoteAmplitude: State = remember { mutableStateOf(0f) }, + onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the // gateway's prompt.submit busy-input policy queues it as the next turn @@ -269,55 +277,64 @@ fun ChatInputBar( } val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - BasicTextField( - value = inputFieldValue, - onValueChange = onInputChange, - modifier = - Modifier - .weight(1f) - .heightIn(min = 42.dp, max = 200.dp) - .padding(vertical = 4.dp) - .testTag("chat_input"), - enabled = isConnected, - textStyle = - MaterialTheme.typography.bodyLarge.copy( - color = palette.text, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - singleLine = false, - maxLines = 8, - cursorBrush = SolidColor(palette.text), - decorationBox = { innerTextField -> - CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyLarge, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - color = palette.placeholder, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + if (isRecordingVoice) { + VoiceNoteRecordingPanel( + amplitude = voiceNoteAmplitude, + modifier = Modifier.weight(1f), + ) + } else { + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = onInputChange, + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp, max = 200.dp) + .padding(vertical = 4.dp) + .testTag("chat_input"), + enabled = isConnected, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = palette.text, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 8, + cursorBrush = SolidColor(palette.text), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + color = palette.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() } - innerTextField() } } - } - }, - ) + }, + ) + } } } @@ -333,10 +350,15 @@ fun ChatInputBar( onSend = onSend, onQueue = { onBusySend(BusySendMode.QUEUE) }, onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, + canInterrupt = canInterrupt, + onStopGeneration = onStopGeneration, onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap, onReasoningSelected = onReasoningTap, onMicTap = onMicTap, + onMicHoldStart = onMicHoldStart, + onMicHoldEnd = onMicHoldEnd, + onMicHoldCancel = onMicHoldCancel, modifier = Modifier.testTag("chat_composer_toolbar"), canDisableReasoning = canDisableReasoning, supportsReasoning = supportsReasoning, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt index afb68002..fe8dd7d8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -12,6 +12,9 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +30,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.SpeechInputHelper +import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder +import kotlinx.coroutines.delay import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -34,10 +39,15 @@ import java.util.Locale class ChatMediaLaunchers( val isListening: Boolean, + val isRecordingVoice: Boolean, + val voiceNoteAmplitude: State, val onMicTap: () -> Unit, val onCameraTap: () -> Unit, val onImageTap: () -> Unit, val onFileTap: () -> Unit, + val onMicHoldStart: () -> Unit = {}, + val onMicHoldEnd: () -> Unit = {}, + val onMicHoldCancel: () -> Unit = {}, ) @Composable @@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers( onInputFieldValueChange: (TextFieldValue) -> Unit, onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit, onAddAttachments: (List) -> Unit, + onVoiceNoteRecorded: (file: File) -> Unit, onShowMessage: (String) -> Unit, launchExternalActivity: (() -> Unit) -> Unit, + isTranscribingVoiceNote: Boolean, context: Context = LocalContext.current, ): ChatMediaLaunchers { var isListening by remember { mutableStateOf(false) } @@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers( val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange) val currentOnAddAttachment by rememberUpdatedState(onAddAttachment) val currentOnAddAttachments by rememberUpdatedState(onAddAttachments) + val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded) val currentOnShowMessage by rememberUpdatedState(onShowMessage) val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity) @@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers( val sttNotAvailableMsg = stringResource(R.string.stt_not_available) val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) val cameraErrorMsg = stringResource(R.string.chat_camera_error) + val voiceRecordFailedMsg = stringResource(R.string.chat_voice_record_failed) + + // Hold-to-record voice note recorder: the clip uploads to the dashboard + // for server-side transcription, unlike the flat tap path below, which + // still drives the on-device recognizer. + val voiceNoteRecorder = remember { VoiceNoteRecorder(context) } + var isRecordingVoice by remember { mutableStateOf(false) } + + // Live mic level for the recording panel — rises fast, decays slowly so + // the meter reads as voice activity instead of flicker. + val voiceNoteAmplitude = remember { mutableStateOf(0f) } + LaunchedEffect(Unit) { + while (true) { + delay(90) + val previous = voiceNoteAmplitude.value + val next = + if (voiceNoteRecorder.isActive) { + maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f) + } else { + 0f + } + if (next != previous) { + voiceNoteAmplitude.value = next + } + } + } + + fun finishVoiceRecording() { + val recordedFile = voiceNoteRecorder.stop() + isRecordingVoice = false + if (recordedFile != null) { + currentOnVoiceNoteRecorded(recordedFile) + } + } + + // App-owned max-duration cap: the timer drives the same + // finishVoiceRecording() path as the hold release, so exactly one code + // path ever calls stop() — MediaRecorder's own auto-stop used to race + // the release and discard a valid max-length clip (review, PR #1250). + LaunchedEffect(isRecordingVoice) { + if (isRecordingVoice) { + delay(VoiceNoteRecorder.MAX_DURATION_MS.toLong()) + if (voiceNoteRecorder.isActive) { + finishVoiceRecording() + } + } + } + + DisposableEffect(voiceNoteRecorder) { + onDispose { + voiceNoteRecorder.cancel() + } + } // Speech-to-text recognition launcher (issue #194) val speechLauncher = @@ -117,6 +183,18 @@ fun rememberChatMediaLaunchers( } } + // Hold-to-record permission: unlike the launcher above, granting does NOT + // auto-start dictation — the user records by holding the mic again. + val voiceNotePermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + ExternalActivityLifecycleGuard.externalActivityReturned() + if (!granted) { + currentOnShowMessage(sttPermissionDeniedMsg) + } + } + // Multi-file picker for attachments (issue #195) val filePickerLauncher = rememberLauncherForActivityResult( @@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers( } val onMicTap: () -> Unit = { - if (isListening) { + if (isTranscribingVoiceNote) { + // Single-flight: a server transcription owns the voice pipeline + // until it lands (review, PR #1250). + } else if (isRecordingVoice) { + // A tap while recording discards the in-flight voice note. + voiceNoteRecorder.cancel() + isRecordingVoice = false + } else if (isListening) { isListening = false } else if ( ContextCompat.checkSelfPermission( @@ -212,6 +297,45 @@ fun rememberChatMediaLaunchers( } } + val onMicHoldStart: () -> Unit = { + if (isTranscribingVoiceNote) { + // Single-flight: no new recording while a transcription runs. + } else if (voiceNoteRecorder.isActive || isListening) { + // A voice note or a dictation session is already running. + } else if ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + ) { + if (voiceNoteRecorder.start()) { + isRecordingVoice = true + } else { + currentOnShowMessage(voiceRecordFailedMsg) + } + } else { + // The denial message comes from voiceNotePermissionLauncher's + // result callback only — showing it here fired it before the + // dialog even opened (review, PR #1250). + currentLaunchExternalActivity { + voiceNotePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + val onMicHoldEnd: () -> Unit = { + if (voiceNoteRecorder.isActive) { + finishVoiceRecording() + } + } + + val onMicHoldCancel: () -> Unit = { + if (voiceNoteRecorder.isActive) { + voiceNoteRecorder.cancel() + } + isRecordingVoice = false + } + val onCameraTap: () -> Unit = { try { val timeStamp = @@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers( } } - return remember(isListening) { + return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) { ChatMediaLaunchers( - isListening = isListening, + isListening = isListening || isRecordingVoice, + isRecordingVoice = isRecordingVoice, + voiceNoteAmplitude = voiceNoteAmplitude, onMicTap = onMicTap, onCameraTap = onCameraTap, onImageTap = onImageTap, onFileTap = onFileTap, + onMicHoldStart = onMicHoldStart, + onMicHoldEnd = onMicHoldEnd, + onMicHoldCancel = onMicHoldCancel, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 943967ef..9fcd992b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -51,12 +53,16 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -67,6 +73,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import kotlinx.coroutines.withTimeoutOrNull /** * Bottom controls row for the chat composer, rendered inside the composer card. @@ -116,12 +123,50 @@ fun ComposerToolbar( reasoningWireLevel: String? = null, pendingReasoningLevel: String? = null, isSessionReady: Boolean = true, + canInterrupt: Boolean = false, + onMicHoldStart: () -> Unit = {}, + onMicHoldEnd: () -> Unit = {}, + onMicHoldCancel: () -> Unit = {}, + onStopGeneration: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } val palette = composerPalette() val reasoningDisabledForModel = supportsReasoning == false val canDisable = canDisableReasoning + // Telegram-style voice notes: hold to record, release to send, slide away + // to cancel. Both mic controls run the same single pointer loop + // (micHoldHandler), which owns the whole press until finger-up — so the + // release that submits the note can never be lost between recompositions. + val currentIsConnected = rememberUpdatedState(isConnected) + val currentShowSend = rememberUpdatedState(showSend) + val currentCanInterrupt = rememberUpdatedState(canInterrupt) + val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart) + val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd) + val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel) + val flatMicGesture = + Modifier.pointerInput(Unit) { + micHoldHandler( + isEnabled = { + currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value + }, + onHoldStart = { currentOnMicHoldStart.value() }, + onHoldEnd = { currentOnMicHoldEnd.value() }, + onHoldCancel = { currentOnMicHoldCancel.value() }, + ) + } + val actionMicGesture = + Modifier.pointerInput(Unit) { + micHoldHandler( + isEnabled = { + currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value + }, + onHoldStart = { currentOnMicHoldStart.value() }, + onHoldEnd = { currentOnMicHoldEnd.value() }, + onHoldCancel = { currentOnMicHoldCancel.value() }, + ) + } + Row( modifier = modifier @@ -417,25 +462,47 @@ fun ComposerToolbar( } } - // Flat mic / stop button — only while the action button is in send mode + // Flat slot — mic while idle; while a generation can be interrupted it + // keeps queue-send available (the action button carries Stop). During + // session preparation the mic stays here and the action button is a + // disabled send: Stop must not appear when there is nothing to + // interrupt (review, PR #1250). AnimatedVisibility( visible = showSend, enter = fadeIn() + scaleIn(initialScale = 0.8f), exit = fadeOut() + scaleOut(targetScale = 0.8f), ) { - FilledIconButton( - onClick = onMicTap, - enabled = isConnected, - colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), - modifier = - Modifier - .size(ControlSize) - .testTag(if (isListening) "mic_stop_button" else "mic_button"), - ) { - Icon( - imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, - contentDescription = if (isListening) "Stop listening" else "Mic", - ) + if (canInterrupt) { + FilledIconButton( + onClick = onSend, + enabled = canSend, + colors = flatIconButtonColors(palette), + modifier = + Modifier + .size(ControlSize) + .testTag("send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.chat_send_desc), + ) + } + } else { + FilledIconButton( + onClick = onMicTap, + enabled = isConnected, + colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + modifier = + Modifier + .size(ControlSize) + .testTag(if (isListening) "mic_stop_button" else "mic_button") + .then(flatMicGesture), + ) { + Icon( + imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, + contentDescription = if (isListening) "Stop listening" else "Mic", + ) + } } } @@ -484,21 +551,29 @@ fun ComposerToolbar( } }.combinedClickable( enabled = actionEnabled, - onClick = if (showSend) onSend else onMicTap, + onClick = { + when { + canInterrupt -> onStopGeneration() + showSend -> onSend() + else -> onMicTap() + } + }, onLongClick = if (showSend && showQueue) onStopAndSend else null, onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, ).testTag( when { + canInterrupt -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" else -> "mic_button" }, - ), + ).then(if (showSend || canInterrupt) Modifier else actionMicGesture), contentAlignment = Alignment.Center, ) { Crossfade( targetState = when { + canInterrupt -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP else -> ActionGlyph.VOICE @@ -517,8 +592,18 @@ fun ComposerToolbar( ActionGlyph.STOP -> { Icon( imageVector = Icons.Default.Stop, - contentDescription = "Stop listening", - tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction, + contentDescription = + if (canInterrupt) { + stringResource(R.string.chat_voice_stop_generating) + } else { + "Stop listening" + }, + tint = + if (isListening && !canInterrupt) { + MaterialTheme.colorScheme.onErrorContainer + } else { + palette.onAction + }, ) } @@ -632,3 +717,91 @@ fun buildReasoningLabel( } return reqLabel } + +/** + * Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS] + * records a voice note, release sends it, and sliding away (left or up) from + * the button cancels instead of sending. Short taps use the control's normal + * click handler for dictation, so touch and accessibility actions share one + * callback path. + * + * The whole down-to-up sequence lives in this one pointer loop, so the release + * that submits can never be lost to a recomposition between press and + * finger-up — an earlier interaction-source based approach could lose it and + * leave the recorder running until some later tap "sent" the stale clip. + */ +private suspend fun PointerInputScope.micHoldHandler( + isEnabled: () -> Boolean, + onHoldStart: () -> Unit, + onHoldEnd: () -> Unit, + onHoldCancel: () -> Unit, +) { + val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx() + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!isEnabled()) { + return@awaitEachGesture + } + + fun slidAway(position: Offset): Boolean { + val dx = position.x - down.position.x + val dy = position.y - down.position.y + return dx < -cancelSlop || dy < -cancelSlop + } + + // Phase 1 — a finger-up before the threshold is a plain tap. + var earlyUp = false + var gestureAborted = false + withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) { + while (true) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { it.id == down.id } + ?: run { + gestureAborted = true + return@withTimeoutOrNull + } + if (!change.pressed) { + earlyUp = true + return@withTimeoutOrNull + } + if (change.isConsumed || slidAway(change.position)) { + gestureAborted = true + return@withTimeoutOrNull + } + } + } + if (gestureAborted) { + return@awaitEachGesture + } + if (earlyUp) { + return@awaitEachGesture + } + + // Phase 2 — the threshold passed with the finger down: record until + // the finger lifts (send) or slides away (cancel). No timeout here. + onHoldStart() + var send = false + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + send = true + break + } + if (change.isConsumed || slidAway(change.position)) { + break + } + } + } finally { + if (send) onHoldEnd() else onHoldCancel() + } + } +} + +/** A press shorter than this is a tap; longer arms voice-note recording. */ +private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L + +/** Sliding this far from the mic button (left or up) cancels the recording. */ +private const val MIC_SLIDE_CANCEL_DP = 64 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt new file mode 100644 index 00000000..1cca004a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt @@ -0,0 +1,128 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.os.SystemClock +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import kotlinx.coroutines.delay + +/** + * Telegram-style recording strip shown in place of the input field while a + * voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and + * a live mic level meter so the press and the captured audio are both visible + * even when the mic button itself is under the thumb. + */ +@Composable +internal fun VoiceNoteRecordingPanel( + amplitude: State, + modifier: Modifier = Modifier, +) { + val palette = composerPalette() + var elapsedSeconds by remember { mutableStateOf(0L) } + LaunchedEffect(Unit) { + val startedAt = SystemClock.elapsedRealtime() + while (true) { + elapsedSeconds = (SystemClock.elapsedRealtime() - startedAt) / 1000 + delay(200) + } + } + val levels = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + snapshotFlow { amplitude.value }.collect { level -> + levels.add(level) + while (levels.size > VOICE_NOTE_BARS) { + levels.removeAt(0) + } + } + } + Row( + modifier = + modifier + .heightIn(min = 42.dp) + .testTag("voice_note_recording_panel"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + // Decorative: the adjacent "Slide to cancel" text carries the + // instruction, so the arrow must not repeat it (review, PR #1250). + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource(R.string.chat_voice_slide_to_cancel), + style = MaterialTheme.typography.labelMedium, + color = palette.placeholder, + maxLines = 1, + ) + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = + Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + ) + Text( + text = formatElapsed(elapsedSeconds), + style = MaterialTheme.typography.labelLarge, + color = palette.text, + ) + Row( + modifier = Modifier.height(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + val filler = VOICE_NOTE_BARS - levels.size + repeat(VOICE_NOTE_BARS) { index -> + val level = if (index < filler) 0f else levels[index - filler] + Box( + modifier = + Modifier + .width(3.dp) + .height((4f + 18f * level).dp) + .background(palette.placeholder, RoundedCornerShape(2.dp)), + ) + } + } + } +} + +/** Number of level-meter bars in the recording panel. */ +private const val VOICE_NOTE_BARS = 14 + +/** Formats elapsed recording time as m:ss. */ +private fun formatElapsed(seconds: Long): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "$minutes:${secs.toString().padStart(2, '0')}" +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6d1261c..5fcd2512 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,9 @@ Send Listening… + Could not start voice recording + Slide to cancel + Stop generating Microphone permission is required for voice input Speech recognition is not available on this device Send diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index 97a5a730..e7096b39 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.local.AuthManager import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest { server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val builder = Request.Builder().url(server.url(path)) when (method) { + "POST" -> builder.post("{}".toRequestBody("application/json".toMediaType())) "DELETE" -> builder.delete() else -> builder.get() } @@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest { assertEquals("work", url.queryParameter("profile")) assertEquals("/api/status/health", url.encodedPath) } + + @Test + fun transcribeRoute_getsTheActiveNonDefaultProfile() { + // Review (PR #1250): /api/audio/transcribe resolves STT through the + // request's profile on the backend — the desktop sends this route + // `...profileScoped()`. Unscoped, a multi-profile host would + // transcribe under the launch profile instead of the user's pick. + val client = clientFor("work") + + val url = requestedUrl(client, "api/audio/transcribe", method = "POST") + + assertEquals("work", url.queryParameter("profile")) + assertEquals("/api/audio/transcribe", url.encodedPath) + } + + @Test + fun lookalikeAudioPath_notScoped() { + val client = clientFor("work") + + val url = requestedUrl(client, "api/audio-extras") + + assertNull(url.queryParameter("profile")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt new file mode 100644 index 00000000..e1387b9a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt @@ -0,0 +1,133 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.remote.NetworkResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response +import java.io.File +import java.net.SocketTimeoutException + +class VoiceNoteRepositoryTest { + private val api = mockk() + + private fun tempVoiceFile(bytes: ByteArray = byteArrayOf(1)): File = + File.createTempFile("voice-note-test", ".m4a").apply { + writeBytes(bytes) + deleteOnExit() + } + + @Test + fun transcribe_uploadsBase64DataUrlAndReturnsTrimmedTranscript() = + runTest { + val file = tempVoiceFile(byteArrayOf(1, 2, 3)) + val bodySlot = slot() + coEvery { api.transcribeAudio(capture(bodySlot)) } returns + Response.success( + AudioTranscriptionResponse(ok = true, transcript = " hello world "), + ) + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + val success = result as NetworkResult.Success + assertEquals("hello world", success.data) + assertEquals("audio/mp4", bodySlot.captured.mimeType) + assertEquals("data:audio/mp4;base64,AQID", bodySlot.captured.dataUrl) + } + + @Test + fun transcribe_propagatesServerFailureWithoutRetrying() = + runTest { + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } returns + Response.error(422, "no speech".toResponseBody("text/plain".toMediaType())) + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 1) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_doesNotRetryThePostAfterASocketTimeout() = + runTest { + // safeApiCall's default policy retries socket timeouts and 5xx; a + // blind re-POST would repeat provider STT work after the request + // may already have reached the server (review, PR #1250). + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } throws SocketTimeoutException("stt timeout") + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 1) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() = + runTest { + val firstApi = mockk() + val secondApi = mockk() + coEvery { firstApi.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "old server")) + coEvery { secondApi.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "new server")) + var currentApi = firstApi + val repository = VoiceNoteRepository { _ -> currentApi } + + val before = repository.transcribe(tempVoiceFile()) + // Simulate ApiClient.rebuild() after a connection/profile switch: + // the next call must use the new service, not the captured one. + currentApi = secondApi + val after = repository.transcribe(tempVoiceFile()) + + assertEquals("old server", (before as NetworkResult.Success).data) + assertEquals("new server", (after as NetworkResult.Success).data) + coVerify(exactly = 1) { firstApi.transcribeAudio(any()) } + coVerify(exactly = 1) { secondApi.transcribeAudio(any()) } + } + + @Test + fun transcribe_requestsTheDesktopParitySttTimeout() = + runTest { + val requestedTimeouts = mutableListOf() + coEvery { api.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "ok")) + + VoiceNoteRepository { timeout -> + requestedTimeouts += timeout + api + }.transcribe(tempVoiceFile()) + + // A tiny clip must still get the 180s floor, not the shared 30s client. + assertEquals(listOf(VoiceNoteRepository.MIN_REQUEST_TIMEOUT_MS), requestedTimeouts) + } + + @Test + fun transcriptionTimeoutMs_scalesWithPayloadThenClamps() { + assertEquals(180_000L, VoiceNoteRepository.transcriptionTimeoutMs("data:audio/mp4;base64,AQID")) + assertEquals(200_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(2_000_000))) + assertEquals(600_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(10_000_000))) + } + + @Test + fun transcribe_unreadableFileReturnsFailureWithoutUploading() = + runTest { + val missing = tempVoiceFile().apply { delete() } + + val result = VoiceNoteRepository { _ -> api }.transcribe(missing) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 0) { api.transcribeAudio(any()) } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index 3c0c3b57..f3360b34 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -76,6 +77,14 @@ class ChatUpdateCommandTest { every { android.util.Log.e(any(), any(), any()) } returns 0 mockkObject(AuthManager) + // ChatModelSwitchDelegate.preloadModelOptions() reads pinned models + // when the catalog load succeeds; without this stub the spy falls + // through to the real AuthManager and throws "not initialized". + every { AuthManager.getPinnedModels() } returns emptyList() + // The delegate and the shared catalog store both collect this flow; + // park them on a never-emitting state so a later real-AuthManager + // emission cannot resume a stale Main-dispatched collector. + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) mockkObject(HermesWsClient) mockkObject(ApiClient) mockkObject(HermesDatabase) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index f750132f..c2a9f6f5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -8,9 +8,11 @@ import android.net.Uri import android.util.Log import androidx.core.content.FileProvider import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PaginationInfo import com.m57.hermescontrol.data.model.SessionMessage @@ -30,6 +32,7 @@ import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.JsonRpcError +import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.notification.TurnCorrelationTracker @@ -65,6 +68,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertArrayEquals @@ -75,6 +79,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class ChatViewModelTest { @@ -298,6 +303,18 @@ class ChatViewModelTest { every { AuthManager.getToken() } returns "test-token" every { AuthManager.getBaseUrl() } returns "http://test.local/" every { AuthManager.getSelectedProfileId() } returns null + // sendVoiceNote snapshots the data scope for its ownership check + // (review, PR #1250). Default it to null: an unstubbed mockk call + // used to throw and be swallowed by the runCatching callers, which + // kept SwrCache keys unscoped in this class; returning a concrete + // scope here enables cross-test cache hits and skips sends other + // tests count. Scope-specific tests re-stub this with real scopes. + every { AuthManager.currentDataScope() } returns null + // ChatViewModel's model-switch delegate and the shared catalog store + // both collect this flow; park them on a never-emitting state so a + // later real-AuthManager emission cannot resume a stale + // Main-dispatched collector between test classes. + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) mockkObject(ProfileSwitchCoordinator) every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() @@ -1098,6 +1115,13 @@ class ChatViewModelTest { runTest { val (viewModel, _) = createViewModelWithSession() + // The shared catalog store may still hold another class's cached + // (possibly empty) state — reset it so the picker's load below + // fetches through this class's stubbed API. + ModelCatalogStore.shared.onScopeChanged( + DataScope("preload-reset", "http://preload-reset.test/", AuthManager.DEFAULT_PROFILE_ID), + ) + // A bare "/model" must NOT dispatch a slash command; it opens the picker. viewModel.sendMessage("/model") advanceUntilIdle() @@ -9746,4 +9770,414 @@ class ChatViewModelTest { } assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId)) } + + @Test + fun sendVoiceNote_transcribesViaServerAndSubmitsTranscript() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.success( + AudioTranscriptionResponse(ok = true, transcript = "hello from voice"), + ) + val (viewModel, _) = createViewModelWithSession() + + val submittedTexts = mutableListOf() + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + submittedTexts += arg(1) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_note.m4a").apply { + writeBytes(byteArrayOf(1, 2, 3)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertEquals("hello from voice", submittedTexts.lastOrNull()) + assertFalse(voiceFile.exists()) + assertNull(viewModel.uiState.value.errorMessage) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + } + + @Test + fun sendVoiceNote_reportsTranscriptionFailureAndDeletesClip() = + runTest { + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.error(500, "transcribe failed".toResponseBody("text/plain".toMediaType())) + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_fail.m4a").apply { + writeBytes(byteArrayOf(9)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("Voice note transcription failed") == true, + ) + assertFalse(voiceFile.exists()) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + } + + @Test + fun sendVoiceNote_emptyTranscriptShowsNoSpeechAndDoesNotSubmit() = + runTest { + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = " ")) + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_silent.m4a").apply { + writeBytes(byteArrayOf(0)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("No speech detected") == true, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun sendVoiceNote_whileDisconnectedDropsClipLocallyWithoutUploading() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_offline.m4a").apply { + writeBytes(byteArrayOf(1)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertFalse(voiceFile.exists()) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("not connected") == true, + ) + } + + @Test + fun sendVoiceNote_whenSessionChangesMidTranscription_keepsTheTranscriptInTheComposer() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_session_race.m4a").apply { + writeBytes(byteArrayOf(7)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + // The user moves to a new session while STT is still running. + viewModel.createNewSession() + advanceUntilIdle() + + transcription.complete( + Response.success(AudioTranscriptionResponse(ok = true, transcript = "late transcript")), + ) + advanceUntilIdle() + + // The recording belonged to the old session: the transcript must + // survive in the composer instead of landing in a session it was + // never recorded for, or vanishing (review, PR #1250). + assertEquals("late transcript", viewModel.uiState.value.composerTextToRestore) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + assertFalse(voiceFile.exists()) + } + + @Test + fun sendVoiceNote_whenTheSendIsRejected_keepsTheTranscriptInTheComposer() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "keep me")) + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_send_race.m4a").apply { + writeBytes(byteArrayOf(8)) + } + viewModel.sendVoiceNote(voiceFile) + // The connection drops while the transcript is being produced. + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + advanceUntilIdle() + + assertEquals("keep me", viewModel.uiState.value.composerTextToRestore) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun sendVoiceNote_whileATranscriptionIsInFlight_dropsTheSecondClip() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + val (viewModel, _) = createViewModelWithSession() + + val submittedTexts = mutableListOf() + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + submittedTexts += arg(1) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + val firstFile = + java.io.File(attachmentCacheDir, "voice_first.m4a").apply { writeBytes(byteArrayOf(1)) } + val secondFile = + java.io.File(attachmentCacheDir, "voice_second.m4a").apply { writeBytes(byteArrayOf(2)) } + viewModel.sendVoiceNote(firstFile) + advanceUntilIdle() + + viewModel.sendVoiceNote(secondFile) + advanceUntilIdle() + + // Single-flight: the second clip is dropped without a second upload. + assertFalse(secondFile.exists()) + coVerify(exactly = 1) { api.transcribeAudio(any()) } + + transcription.complete( + Response.success(AudioTranscriptionResponse(ok = true, transcript = "only once")), + ) + advanceUntilIdle() + + assertEquals(listOf("only once"), submittedTexts) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + } + + @Test + fun sendVoiceNote_whenDataScopeChangesDuringTranscription_doesNotSendIntoNewScope() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "alpha", + baseUrl = "http://alpha.test/", + activeProfileId = AuthManager.DEFAULT_PROFILE_ID, + ) + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + // A pending SESSION_CREATE is all this test needs; skipping the + // gateway preload keeps the shared ModelCatalogStore out of it. + viewModel.createNewSession() + advanceUntilIdle() + + // SESSION_CREATE is still pending, so the storage session ID is + // null — exactly the window where the session-ID snapshot alone + // cannot detect a scope move. + assertNull(viewModel.uiState.value.currentSessionId) + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_scope_switch.m4a").apply { + writeBytes(byteArrayOf(1, 2, 3)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isTranscribingVoiceNote) + + // The user switches to another server/profile while STT runs; the + // replacement session create is pending as well, so both session + // IDs can stay null/null and only the data scope moves. + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "beta", + baseUrl = "http://beta.test/", + activeProfileId = AuthManager.DEFAULT_PROFILE_ID, + ) + advanceUntilIdle() + + transcription.complete( + Response.success( + AudioTranscriptionResponse(ok = true, transcript = "do not send this to the new server"), + ), + ) + advanceUntilIdle() + + // The transcript must not become the initial prompt of the new + // scope; it stays in the composer instead (review, PR #1250). + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), "do not send this to the new server", any(), any()) + } + assertEquals( + "do not send this to the new server", + viewModel.uiState.value.composerTextToRestore, + ) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + assertFalse(voiceFile.exists()) + } + + @Test + fun sendVoiceNote_whenServerProfileChangesDuringTranscription_doesNotSendIntoNewScope() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "alpha", + baseUrl = "http://alpha.test/", + activeProfileId = "default", + ) + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + // A pending SESSION_CREATE is all this test needs; skipping the + // gateway preload keeps the shared ModelCatalogStore out of it. + viewModel.createNewSession() + advanceUntilIdle() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_profile_switch.m4a").apply { + writeBytes(byteArrayOf(4, 5, 6)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isTranscribingVoiceNote) + + // Same server, different server-side Hermes profile: the session + // IDs cannot see this change either, so the scope must. + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "alpha", + baseUrl = "http://alpha.test/", + activeProfileId = "work", + ) + advanceUntilIdle() + + transcription.complete( + Response.success( + AudioTranscriptionResponse(ok = true, transcript = "profile-scoped transcript"), + ), + ) + advanceUntilIdle() + + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), "profile-scoped transcript", any(), any()) + } + assertEquals( + "profile-scoped transcript", + viewModel.uiState.value.composerTextToRestore, + ) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + assertFalse(voiceFile.exists()) + } + + @Test + fun canInterrupt_staysFalseWhileTypingWithNoRuntimeSession_thenTracksTheGeneration() = + runTest { + stubActiveProfile() + val vm = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + val sentPrompts = mutableListOf() + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + sentPrompts += arg(1) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // Queued while session create is in flight: sending is pending, + // but the session has no runtime to interrupt yet, so Stop must stay + // hidden and interruptSession() must not be reachable from it + // (review, PR #1250). + vm.sendMessage("held under session preparation") + advanceUntilIdle() + assertTrue(vm.uiState.value.isSending) + assertFalse(vm.uiState.value.canInterrupt) + + // Once the runtime session exists and the prompt is dispatched, + // the running generation is interruptible. Read the captured + // session.create id instead of counting sends: a singleton store + // warmed by an earlier test can fire an extra model.options send + // in this gateway flow and shift the counter (see the resume + // tests, same pattern). + val createReqId = + sentRequestMethods.last { it.first == WsMethods.SESSION_CREATE }.second + mockEventsFlow.emit( + WsEvent.RpcResult( + createReqId, + mapOf("session_id" to "session-969", "stored_session_id" to "session-storage-969"), + ), + ) + advanceUntilIdle() + + assertEquals(listOf("held under session preparation"), sentPrompts) + // A successful prompt.submit acknowledgement only means the + // gateway accepted it; the generation is interruptible only once + // the runtime reports message.start for its session. + mockEventsFlow.emit(WsEvent.MessageStart("session-969")) + advanceUntilIdle() + assertTrue(vm.uiState.value.canInterrupt) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 60cb31a2..1447a65e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.PaginationInfo import com.m57.hermescontrol.data.model.SessionMessagesResponse @@ -77,6 +78,14 @@ class SlashCommandDispatchRpcTest { // COMMAND_DISPATCH captures in suite order). setMain alone is safe — // the slash-dispatch send path is synchronous (no IO hop). mockkObject(AuthManager) + // ChatModelSwitchDelegate.preloadModelOptions() reads pinned models + // when the catalog load succeeds; without this stub the spy falls + // through to the real AuthManager and throws "not initialized". + every { AuthManager.getPinnedModels() } returns emptyList() + // The delegate and the shared catalog store both collect this flow; + // park them on a never-emitting state so a later real-AuthManager + // emission cannot resume a stale Main-dispatched collector. + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) mockkObject(HermesWsClient) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -459,7 +468,11 @@ class SlashCommandDispatchRpcTest { every { HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { - val m = methodCalls.last() + // Read the method from THIS invocation, not from the shared + // capture list: a concurrent request() landing between capture + // and answer can leave .last() pointing at another call (CI: + // "Value not yet captured" for the submitted prompt). + val m = arg(0) val d = CompletableDeferred() if (m == WsMethods.COMMAND_DISPATCH) { // Backend rejects /status with the registry-miss 4018 (issue #576). @@ -729,7 +742,9 @@ class SlashCommandDispatchRpcTest { HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { val d = CompletableDeferred() - if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) { + // Read the method from THIS invocation, not from the shared + // capture list (see the 4018 test above). + if (arg(0) == WsMethods.COMMAND_DISPATCH) { // Backend /init returns type:"send" with the AGENTS.md prompt. d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md")) } else {