diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt new file mode 100644 index 00000000..5c9a80c1 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt @@ -0,0 +1,294 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.ws.CommandCatalog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Gesture-level coverage for the Telegram-style voice-note mic: a held press + * arms the recorder, the release submits exactly once, sliding left cancels, + * sliding up locks hands-free, and the shared click handlers must never + * re-dispatch the dictation (mic tap) action for a hold. + * + * Regression: the action button's click handler fires on ANY stationary + * release — including one that ended a hold — so letting go of a voice note + * also launched the system speech-recognizer intent. The gesture loop now + * consumes the hold phase, and these tests pin that behavior down. + * + * The hold threshold runs on the compose test clock, so the tests advance + * [createAndroidComposeRule]'s mainClock instead of sleeping. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class VoiceNoteGestureTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private var micTaps = 0 + private var holdStarts = 0 + private var holdEnds = 0 + private var holdCancels = 0 + private var sendCount = 0 + private var locks = 0 + + /** + * Renders the real input bar with the launcher's contract: hold start/end + * flip the recording state, the lock callback flips the locked flag, and a + * cancel releases both — exactly like ChatMediaLaunchers does. + */ + private fun setComposer( + initiallyLocked: Boolean = false, + draft: String = "", + ) { + // The lock-hint tooltip hides after the first successful lock and + // persists that; start every run from a clean slate so the rendered + // UI is deterministic. + composeTestRule.activity + .getSharedPreferences("chat_voice_prefs", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + composeTestRule.setContent { + var locked by remember { mutableStateOf(initiallyLocked) } + var recording by remember { mutableStateOf(initiallyLocked) } + // Bottom-anchor the bar like the real chat screen, so floating + // overlays above the card render inside the captured frame. + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.weight(1f)) + ChatInputBar( + inputFieldValue = TextFieldValue(draft), + onInputChange = {}, + onSend = { sendCount++ }, + onMicTap = { micTaps++ }, + isListening = false, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + onMicHoldStart = { + holdStarts++ + recording = true + }, + onMicHoldEnd = { + holdEnds++ + recording = false + }, + onMicHoldCancel = { + holdCancels++ + locked = false + recording = false + }, + onMicLock = { + locks++ + locked = true + }, + isVoiceNoteLocked = locked, + isRecordingVoice = recording, + ) + } + } + } + + @Test + fun stationaryHoldRelease_sendsWithoutTriggeringMicTap() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + // Mid-hold capture: the recording strip must be visible even though + // the mic button sits under the finger (device follow-up, #1247). + val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_recording.png") + .outputStream() + .use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) } + // The lock hint must exist while holding (it sits above the card). + composeTestRule.onNodeWithText("Slide up to lock recording").assertExists() + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("release must submit the note", 1, holdEnds) + assertEquals( + "a held release must not re-dispatch the tap action", + 0, + micTaps, + ) + assertEquals("a submitted hold must not cancel", 0, holdCancels) + } + } + + @Test + fun quickTap_stillDispatchesMicTap() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { + down(center) + up() + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("a quick tap must start dictation", 1, micTaps) + assertEquals(0, holdStarts) + assertEquals(0, holdEnds) + } + } + + @Test + fun holdSlideAway_cancelsWithoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(-240f, 0f)) } + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("sliding left must cancel the recording", 1, holdCancels) + assertEquals("a cancelled hold must not submit", 0, holdEnds) + assertEquals("a cancelled hold must not start dictation", 0, micTaps) + } + } + + @Test + fun holdSlideUp_locksWithoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) } + // Lifting the finger after the slide-up lock: target the root, because + // the action slot has already morphed into the send button. + composeTestRule.onRoot().performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("sliding up must lock the recording", 1, locks) + assertEquals("a locked hold must not submit on release", 0, holdEnds) + assertEquals("a locked hold must not cancel", 0, holdCancels) + assertEquals("a locked hold must not start dictation", 0, micTaps) + } + + composeTestRule.onNodeWithTag("voice_note_send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("voice_note_send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("the locked send button must dispatch the mic action", 1, micTaps) + assertEquals("sending must not re-lock", 1, locks) + } + } + + @Test + fun holdRelease_returnsFocusToTheInput() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performClick() + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + // The strip replaced the input field while recording; the send must + // hand focus back so the keyboard re-opens without an extra tap + // (device follow-up, #1247). + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() + } + + @Test + fun lockedRecording_showsCancelActionAndRoutesCancel() { + setComposer(initiallyLocked = true) + + composeTestRule.onNodeWithTag("voice_note_recording_panel").assertIsDisplayed() + composeTestRule.onNodeWithTag("voice_note_cancel_button").assertIsDisplayed() + // Accessibility (review, PR #1280): the locked CANCEL is a real + // button. Its hit area is expanded by minimumInteractiveComponentSize + // (Compose keeps the visual text compact and widens the touch target), + // so the assertion pins the button role rather than layout bounds. + composeTestRule + .onNodeWithTag("voice_note_cancel_button") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + + val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + val file = + File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_locked.png") + file.outputStream().use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) } + + composeTestRule.onNodeWithTag("voice_note_cancel_button").performClick() + composeTestRule.runOnIdle { + assertEquals("cancel must cancel the recording", 1, holdCancels) + } + } + + @Test + fun holdReleaseWithDraft_doesNotRouteTapToDictation() { + // With a draft present the secondary mic ("mic_button") appears next to + // the input. Review, PR #1280: its gesture modifier is chained after + // the button's own clickable (the reverse of the action button), so a + // stationary hold-release must not leak a finger-up into onMicTap. + setComposer(draft = "draft") + val tapsBefore = micTaps + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("release must submit the note", 1, holdEnds) + assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps) + } + } + + @Test + fun lockedWithDraft_actionButtonFinishesTheVoiceNote() { + // Blocker, review, PR #1280: with a draft present the action button is + // a send arrow; once the recording is locked it must finish the voice + // note instead of sending the draft text. + setComposer(draft = "draft") + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) } + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("voice_note_send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("the tap must finish the voice note", 1, micTaps) + assertEquals("the draft text must not be sent", 0, sendCount) + } + } +} 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 369d9a43..584e816d 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 @@ -95,9 +95,12 @@ object ApiClient { * 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). + * the shared connection pool and the auth/profile interceptor stack. It + * applies [readTimeoutMs] to reads and writes and keeps transport-level + * retries on: they re-dial and can resend depending on when a failure + * struck, so they do not guarantee the absence of duplicate provider + * work — the risk is small, not zero. Application-level retry stays off + * (review, PR #1250 / #1280). */ fun transcriptionService(readTimeoutMs: Long): HermesApiService { val client = @@ -109,7 +112,16 @@ object ApiClient { .authenticator(TokenRefreshAuthenticator) .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) .writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) - .retryOnConnectionFailure(false) + // Transport-level stale-socket rescue stays ON: the shared pool + // keeps connections far longer than the dashboard's keep-alive, + // so the first STT call after an idle spell can ride a socket + // the server just closed ("unexpected end of stream"). OkHttp + // re-dials and can resend depending on when the failure struck, + // and it may retry across routes — duplicate provider work is + // possible, though rare. Timeouts and 5xx are never replayed + // (safeApiCall still runs with retries = 0). Risk small, not + // zero (review, PR #1280). + .retryOnConnectionFailure(true) .build() return Retrofit .Builder() 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 index 3dabae8a..2c15a06d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt @@ -1,13 +1,17 @@ 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.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 kotlinx.coroutines.delay import java.io.File import java.io.IOException +import java.net.ConnectException +import java.net.UnknownHostException import java.util.Base64 /** @@ -33,6 +37,10 @@ const val VOICE_NOTE_MIME_TYPE = "audio/mp4" * - 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. + * - Connect-never-established failures (unreachable route, DNS blip — the + * usual suspects across Wi-Fi/cellular switches) get ONE delayed + * re-attempt: that request never reached the server, so no provider work + * is duplicated (device follow-up, #1247). * * Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern). */ @@ -54,16 +62,23 @@ open class VoiceNoteRepository( ) } 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), - ) + val service = serviceProvider(transcriptionTimeoutMs(dataUrl)) + val result = requestTranscription(service, dataUrl) + // One bounded re-attempt, and only for connection-never-established + // failures — the one class where the request provably never reached + // the server, so no provider work is duplicated (device follow-up, + // #1247). + val finalResult = + if (result is NetworkResult.Failure && isConnectNeverEstablished(result.error)) { + delay(CONNECT_RETRY_DELAY_MS) + requestTranscription(service, dataUrl) + } else { + result } - return when (result) { + return when (finalResult) { is NetworkResult.Success -> { NetworkResult.Success( - result.data + finalResult.data ?.transcript .orEmpty() .trim(), @@ -71,11 +86,26 @@ open class VoiceNoteRepository( } is NetworkResult.Failure -> { - result + finalResult } } } + private suspend fun requestTranscription( + service: HermesApiService, + dataUrl: String, + ): NetworkResult = + safeApiCall(retries = 0) { + service.transcribeAudio( + AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), + ) + } + + private fun isConnectNeverEstablished(error: NetworkError): Boolean { + val cause = (error as? NetworkError.Connection)?.cause ?: return false + return cause is ConnectException || cause is UnknownHostException + } + companion object { /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */ const val MIN_REQUEST_TIMEOUT_MS = 180_000L @@ -85,6 +115,9 @@ open class VoiceNoteRepository( private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L + /** One re-attempt window for connection-never-established failures. */ + private const val CONNECT_RETRY_DELAY_MS = 750L + /** * Timeout for the blocking STT endpoint, mirroring the desktop's * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char 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 ee9d91da..49585ae6 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 @@ -908,9 +908,10 @@ fun ChatScreen( onMicHoldStart = mediaLaunchers.onMicHoldStart, onMicHoldEnd = mediaLaunchers.onMicHoldEnd, onMicHoldCancel = mediaLaunchers.onMicHoldCancel, + onMicLock = mediaLaunchers.onMicLock, isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isRecordingVoice = mediaLaunchers.isRecordingVoice, - voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude, + isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, isMainTurnBusy = state.isMainTurnBusy, 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 ea3651f2..79638974 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -9,6 +10,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,11 +18,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -30,6 +35,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -47,9 +53,14 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -113,8 +124,9 @@ fun ChatInputBar( onMicHoldStart: () -> Unit = {}, onMicHoldEnd: () -> Unit = {}, onMicHoldCancel: () -> Unit = {}, + onMicLock: () -> Unit = {}, + isVoiceNoteLocked: Boolean = false, isRecordingVoice: Boolean = false, - voiceNoteAmplitude: State = remember { mutableStateOf(0f) }, onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the @@ -130,260 +142,408 @@ fun ChatInputBar( // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } + // The voice-note strip replaces the input field, so the IME closes the + // moment recording starts. When the strip goes away (send, delete, or + // cancel), focus returns to the input and the keyboard re-opens if it was + // up before — the next message must not need an extra tap (device + // follow-up, #1247). + val inputFocusRequester = remember { FocusRequester() } + val softwareKeyboard = LocalSoftwareKeyboardController.current + var inputWasFocused by remember { mutableStateOf(false) } + var restoreInputFocus by remember { mutableStateOf(false) } + // Snapshot BEFORE the launcher flips isRecordingVoice — by the time the + // recomposition swaps the field out, focus is already on its way to the + // root, so the effect alone cannot read it reliably. + val handleMicHoldStart = { + restoreInputFocus = inputWasFocused + onMicHoldStart() + } + // A hold can arm and still fail to start the recorder (permission denied, + // mic busy). Drop the focus snapshot then, so the end of some later + // recording cannot pop the keyboard on its behalf (review, PR #1280). + val handleMicHoldEnd = { + if (!isRecordingVoice) restoreInputFocus = false + onMicHoldEnd() + } + val handleMicHoldCancel = { + if (!isRecordingVoice) restoreInputFocus = false + onMicHoldCancel() + } + LaunchedEffect(isRecordingVoice) { + if (!isRecordingVoice && restoreInputFocus) { + restoreInputFocus = false + inputFocusRequester.requestFocus() + softwareKeyboard?.show() + } + } + // Telegram parity: the strip's hint slides and fades with the drag, so + // the gesture reports its 1 → 0 cancel fraction here. + val voiceSlideProgress = remember { mutableStateOf(1f) } + // The "Slide up to lock recording" tooltip shows until the first + // successful lock, then stays away (persisted, like Telegram). + val context = LocalContext.current + var voiceLockHintDone by remember { + mutableStateOf( + context + .getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(VOICE_LOCK_HINT_KEY, false), + ) + } + LaunchedEffect(isVoiceNoteLocked) { + if (isVoiceNoteLocked && !voiceLockHintDone) { + voiceLockHintDone = true + context + .getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(VOICE_LOCK_HINT_KEY, true) + .apply() + } + } val palette = composerPalette() BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } - AnimatedVisibility( - visible = true, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - // One floating card holds the whole composer: suggestions, attachments, - // the input and the controls row. Flat fill, hairline edge, no shadow. - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.large, - color = palette.card, - border = BorderStroke(width = 1.dp, color = palette.cardBorder), + Box { + AnimatedVisibility( + visible = true, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), ) { - Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { - // Commands hidden from the suggestion menu — desktop/CLI-only and - // TUI-only commands that don't function on mobile (issue #574). - // Single source of truth: CommandBlocklist.UNSUPPORTED, which is - // also enforced at dispatch time (issue #576, deliverable #3). - val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED - val commandNames = - (commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")) - .distinct() - .filter { it.lowercase() !in hiddenSlashDisplay } + // One floating card holds the whole composer: suggestions, attachments, + // the input and the controls row. Flat fill, hairline edge, no shadow. + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + color = palette.card, + border = BorderStroke(width = 1.dp, color = palette.cardBorder), + ) { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { + // Commands hidden from the suggestion menu — desktop/CLI-only and + // TUI-only commands that don't function on mobile (issue #574). + // Single source of truth: CommandBlocklist.UNSUPPORTED, which is + // also enforced at dispatch time (issue #576, deliverable #3). + val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED + val commandNames = + ( + commandCatalog.pairs.map { it[0] } + + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop") + ).distinct() + .filter { it.lowercase() !in hiddenSlashDisplay } - androidx.compose.animation.AnimatedVisibility( - visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), - enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), - exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), - ) { - val filteredCommands = - ChatInputPolicy.sortSlashSuggestions( - commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, - slashUsageCounts, - ) - if (filteredCommands.isNotEmpty()) { - androidx.compose.material3.Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ), - ) { - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), + androidx.compose.animation.AnimatedVisibility( + visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), + ) { + val filteredCommands = + ChatInputPolicy.sortSlashSuggestions( + commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, + slashUsageCounts, + ) + if (filteredCommands.isNotEmpty()) { + androidx.compose.material3.Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ), ) { - items(filteredCommands, key = { it }) { cmd -> - DropdownMenuItem( - text = { - Text( - cmd, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - }, - onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, - ) + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(filteredCommands, key = { it }) { cmd -> + DropdownMenuItem( + text = { + Text( + cmd, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, + ) + } } } } } - } - // Attachment preview chips - AnimatedVisibility( - visible = pendingAttachments.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LazyRow( + // Attachment preview chips + AnimatedVisibility( + visible = pendingAttachments.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(pendingAttachments) { index, attachment -> + AttachmentChip( + attachment = attachment, + onPreview = { onPreviewAttachment(attachment) }, + onRemove = { onRemoveAttachment(index) }, + ) + } + } + } + + if (isConnected && !isSessionReady) { + Text( + text = + stringResource( + if (sessionPreparationFailed) { + R.string.chat_session_not_ready + } else { + R.string.chat_session_preparing + }, + ), + style = MaterialTheme.typography.labelSmall, + color = palette.placeholder, + modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"), + ) + } + + // ── TOP ROW: Borderless input field ── + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - itemsIndexed(pendingAttachments) { index, attachment -> - AttachmentChip( - attachment = attachment, - onPreview = { onPreviewAttachment(attachment) }, - onRemove = { onRemoveAttachment(index) }, + val placeholderText = + when { + !isConnected -> { + stringResource(R.string.chat_input_placeholder_not_connected) + } + + ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { + stringResource(R.string.chat_input_placeholder_queue) + } + + isAgentTyping -> { + stringResource(R.string.chat_input_placeholder_waiting) + } + + else -> { + stringResource(R.string.chat_input_placeholder_type_message) + } + } + + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection( + inputFieldValue.text, + fallback = ambientLayoutDirection, + ) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl + + if (isRecordingVoice) { + VoiceNoteRecordingPanel( + slideProgress = voiceSlideProgress, + locked = isVoiceNoteLocked, + onCancel = onMicHoldCancel, + modifier = Modifier.weight(1f), ) - } - } - } - - if (isConnected && !isSessionReady) { - Text( - text = - stringResource( - if (sessionPreparationFailed) { - R.string.chat_session_not_ready - } else { - R.string.chat_session_preparing - }, - ), - style = MaterialTheme.typography.labelSmall, - color = palette.placeholder, - modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"), - ) - } - - // ── TOP ROW: Borderless input field ── - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val placeholderText = - when { - !isConnected -> { - stringResource(R.string.chat_input_placeholder_not_connected) - } - - ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { - stringResource(R.string.chat_input_placeholder_queue) - } - - isAgentTyping -> { - stringResource(R.string.chat_input_placeholder_waiting) - } - - else -> { - stringResource(R.string.chat_input_placeholder_type_message) - } - } - - val ambientLayoutDirection = LocalLayoutDirection.current - val inputLayoutDirection = - remember(inputFieldValue.text, ambientLayoutDirection) { - BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) - } - val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - - 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, + } 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) + .focusRequester(inputFocusRequester) + .onFocusChanged { focusState -> + if (!isRecordingVoice) { + inputWasFocused = focusState.isFocused + } + }.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 + }, ) { - 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(), - ) + 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() } } - } - }, - ) + }, + ) + } } } + + // ── BOTTOM ROW: Toolbar ── + ComposerToolbar( + isConnected = isConnected, + currentSessionModel = currentSessionModel, + reasoningLevel = reasoningLevel, + isListening = isListening, + canSend = canSend, + showSend = hasDraft, + showQueue = showBusyActions, + onSend = onSend, + onQueue = { onBusySend(BusySendMode.QUEUE) }, + onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, + canInterrupt = canInterrupt, + onStopGeneration = onStopGeneration, + onAttachTap = { showAttachmentTray = !showAttachmentTray }, + onModelTap = onModelTap, + onReasoningSelected = onReasoningTap, + onMicTap = onMicTap, + onMicHoldStart = handleMicHoldStart, + onMicHoldEnd = handleMicHoldEnd, + onMicHoldCancel = handleMicHoldCancel, + onMicLock = onMicLock, + isVoiceNoteLocked = isVoiceNoteLocked, + onSlideProgress = { voiceSlideProgress.value = it }, + modifier = Modifier.testTag("chat_composer_toolbar"), + canDisableReasoning = canDisableReasoning, + supportsReasoning = supportsReasoning, + fastMode = fastMode, + fastSupported = fastSupported, + isFastModeChanging = isFastModeChanging, + onToggleFastMode = onToggleFastMode, + showModelProvider = showModelProvider, + reasoningWireLevel = reasoningWireLevel, + pendingReasoningLevel = pendingReasoningLevel, + isSessionReady = isSessionReady, + ) + + AttachmentTray( + visible = showAttachmentTray, + onDismissRequest = { showAttachmentTray = false }, + onCameraTap = onCameraTap, + onImageTap = onImageTap, + onFileTap = onFileTap, + ) } - - // ── BOTTOM ROW: Toolbar ── - ComposerToolbar( - isConnected = isConnected, - currentSessionModel = currentSessionModel, - reasoningLevel = reasoningLevel, - isListening = isListening, - canSend = canSend, - showSend = hasDraft, - showQueue = showBusyActions, - 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, - fastMode = fastMode, - fastSupported = fastSupported, - isFastModeChanging = isFastModeChanging, - onToggleFastMode = onToggleFastMode, - showModelProvider = showModelProvider, - reasoningWireLevel = reasoningWireLevel, - pendingReasoningLevel = pendingReasoningLevel, - isSessionReady = isSessionReady, - ) - - AttachmentTray( - visible = showAttachmentTray, - onDismissRequest = { showAttachmentTray = false }, - onCameraTap = onCameraTap, - onImageTap = onImageTap, - onFileTap = onFileTap, - ) } } + // Zero-height anchor so the tooltip and lock icon float above the + // card, over the message list, like Telegram's lock control. It sits + // outside the animated card because AnimatedVisibility clips its + // children to the animation bounds. + Box(modifier = Modifier.fillMaxWidth().height(0.dp)) { + VoiceNoteLockHintOverlay( + visible = isRecordingVoice && !isVoiceNoteLocked, + showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f, + modifier = + Modifier + .wrapContentHeight(align = Alignment.Top, unbounded = true) + .align(Alignment.TopEnd) + .offset(y = (-42).dp) + .padding(end = 12.dp), + ) + } } } +/** + * Telegram-style lock affordance floating over the composer while a voice + * note records: the outlined lock icon with the "Slide up to lock recording" + * tooltip beside it. The tooltip shows only until the first successful lock + * (persisted in [VOICE_PREFS_NAME]); the icon rides along until the recording + * locks hands-free. + */ +@Composable +private fun VoiceNoteLockHintOverlay( + visible: Boolean, + showTextHint: Boolean, + modifier: Modifier = Modifier, +) { + if (!visible) { + return + } + val palette = composerPalette() + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + if (showTextHint) { + Text( + text = stringResource(R.string.chat_voice_lock_hint), + style = MaterialTheme.typography.labelSmall, + color = palette.text, + maxLines = 1, + modifier = + Modifier + .background(palette.card, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Box( + modifier = + Modifier + .size(36.dp) + .background(palette.control, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(18.dp), + ) + } + } +} + +/** Shared prefs file for the voice-note UI. */ +private const val VOICE_PREFS_NAME = "chat_voice_prefs" + +/** Set after the first successful hands-free lock, silencing the hint. */ +private const val VOICE_LOCK_HINT_KEY = "voice_note_lock_hint_done" + /** * Reusable attachment chip composable for showing a pending attachment * with a remove button. 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 fe8dd7d8..1d8aa22f 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 @@ -14,7 +14,6 @@ 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 @@ -40,7 +39,7 @@ import java.util.Locale class ChatMediaLaunchers( val isListening: Boolean, val isRecordingVoice: Boolean, - val voiceNoteAmplitude: State, + val isVoiceNoteLocked: Boolean, val onMicTap: () -> Unit, val onCameraTap: () -> Unit, val onImageTap: () -> Unit, @@ -48,6 +47,7 @@ class ChatMediaLaunchers( val onMicHoldStart: () -> Unit = {}, val onMicHoldEnd: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {}, + val onMicLock: () -> Unit = {}, ) @Composable @@ -85,28 +85,14 @@ fun rememberChatMediaLaunchers( 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 - } - } - } + // Locked = the recording continues after the finger lifts (slide up + // during the hold); the action button then submits it. + var isVoiceNoteLocked by remember { mutableStateOf(false) } fun finishVoiceRecording() { val recordedFile = voiceNoteRecorder.stop() isRecordingVoice = false + isVoiceNoteLocked = false if (recordedFile != null) { currentOnVoiceNoteRecorded(recordedFile) } @@ -264,6 +250,9 @@ fun rememberChatMediaLaunchers( if (isTranscribingVoiceNote) { // Single-flight: a server transcription owns the voice pipeline // until it lands (review, PR #1250). + } else if (isVoiceNoteLocked) { + // The locked recording's action button submits the note. + finishVoiceRecording() } else if (isRecordingVoice) { // A tap while recording discards the in-flight voice note. voiceNoteRecorder.cancel() @@ -334,6 +323,15 @@ fun rememberChatMediaLaunchers( voiceNoteRecorder.cancel() } isRecordingVoice = false + isVoiceNoteLocked = false + } + + // Locking keeps the recorder running once the finger lifts; only the + // panel's delete action or the action button can end it then. + val onMicLock: () -> Unit = { + if (voiceNoteRecorder.isActive) { + isVoiceNoteLocked = true + } } val onCameraTap: () -> Unit = { @@ -369,11 +367,11 @@ fun rememberChatMediaLaunchers( } } - return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) { + return remember(isListening, isRecordingVoice, isVoiceNoteLocked, isTranscribingVoiceNote) { ChatMediaLaunchers( isListening = isListening || isRecordingVoice, isRecordingVoice = isRecordingVoice, - voiceNoteAmplitude = voiceNoteAmplitude, + isVoiceNoteLocked = isVoiceNoteLocked, onMicTap = onMicTap, onCameraTap = onCameraTap, onImageTap = onImageTap, @@ -381,6 +379,7 @@ fun rememberChatMediaLaunchers( onMicHoldStart = onMicHoldStart, onMicHoldEnd = onMicHoldEnd, onMicHoldCancel = onMicHoldCancel, + onMicLock = onMicLock, ) } } 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 9fcd992b..a85c3699 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 @@ -61,8 +61,12 @@ 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -127,6 +131,9 @@ fun ComposerToolbar( onMicHoldStart: () -> Unit = {}, onMicHoldEnd: () -> Unit = {}, onMicHoldCancel: () -> Unit = {}, + onMicLock: () -> Unit = {}, + isVoiceNoteLocked: Boolean = false, + onSlideProgress: (Float) -> Unit = {}, onStopGeneration: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } @@ -134,36 +141,74 @@ fun ComposerToolbar( 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. + // Telegram-style voice notes: hold to record, release to send, slide left + // to cancel, slide up to lock hands-free. 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 currentIsVoiceNoteLocked = rememberUpdatedState(isVoiceNoteLocked) val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart) val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd) val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel) + val currentOnMicLock = rememberUpdatedState(onMicLock) + val currentOnSlideProgress = rememberUpdatedState(onSlideProgress) + // The mic button is under the thumb during the gesture, so the outcome — + // lock or cancel — must be felt as well as seen. + val haptic = LocalHapticFeedback.current + // Telegram's slide-to-cancel span: a third of the composer width, capped + // at [MAX_CANCEL_DISTANCE_DP]. The gesture modifier sits on the mic button + // itself (only ~40 dp wide), so the span derives from the screen width. + val cancelDistancePx = + with(LocalDensity.current) { + minOf( + LocalConfiguration.current.screenWidthDp.dp + .toPx() * 0.35f, + MAX_CANCEL_DISTANCE_DP.dp.toPx(), + ) + } val flatMicGesture = Modifier.pointerInput(Unit) { micHoldHandler( isEnabled = { - currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value + currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value && + !currentIsVoiceNoteLocked.value }, onHoldStart = { currentOnMicHoldStart.value() }, onHoldEnd = { currentOnMicHoldEnd.value() }, - onHoldCancel = { currentOnMicHoldCancel.value() }, + onHoldCancel = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicHoldCancel.value() + }, + onHoldLock = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicLock.value() + }, + onSlideProgress = { currentOnSlideProgress.value(it) }, + cancelDistance = cancelDistancePx, ) } val actionMicGesture = Modifier.pointerInput(Unit) { micHoldHandler( isEnabled = { - currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value + currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value && + !currentIsVoiceNoteLocked.value }, onHoldStart = { currentOnMicHoldStart.value() }, onHoldEnd = { currentOnMicHoldEnd.value() }, - onHoldCancel = { currentOnMicHoldCancel.value() }, + onHoldCancel = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicHoldCancel.value() + }, + onHoldLock = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicLock.value() + }, + onSlideProgress = { currentOnSlideProgress.value(it) }, + cancelDistance = cancelDistancePx, ) } @@ -488,19 +533,30 @@ fun ComposerToolbar( ) } } else { - FilledIconButton( - onClick = onMicTap, - enabled = isConnected, - colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + // Plain Box, not FilledIconButton: the hold gesture must sit + // innermost in the pointer-modifier chain so it consumes the + // release before the tap handler — impossible while the button + // supplies its own clickable (review, PR #1280). Same + // arrangement as the action button below. + val micColors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette) + Box( modifier = Modifier .size(ControlSize) - .testTag(if (isListening) "mic_stop_button" else "mic_button") + .clip(CircleShape) + .background( + if (isConnected) micColors.containerColor else micColors.disabledContainerColor, + ).combinedClickable( + enabled = isConnected, + onClick = onMicTap, + ).testTag(if (isListening) "mic_stop_button" else "mic_button") .then(flatMicGesture), + contentAlignment = Alignment.Center, ) { Icon( imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, contentDescription = if (isListening) "Stop listening" else "Mic", + tint = if (isConnected) micColors.contentColor else micColors.disabledContentColor, ) } } @@ -527,7 +583,17 @@ fun ComposerToolbar( // Action button — send when a send is possible, mic / stop otherwise val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) - val actionEnabled = if (showSend) canSend else isConnected + // While a recording is locked this button finishes the voice note, so + // it must stay enabled even when the draft alone would not send + // (review, PR #1280). + val actionEnabled = + if (isVoiceNoteLocked) { + true + } else if (showSend) { + canSend + } else { + isConnected + } Box( modifier = Modifier @@ -540,7 +606,7 @@ fun ComposerToolbar( palette.action }, ).semantics { - if (showSend && showQueue && canSend) { + if (showSend && showQueue && canSend && !isVoiceNoteLocked) { customActions = listOf( CustomAccessibilityAction(stopAndSendLabel) { @@ -553,15 +619,23 @@ fun ComposerToolbar( enabled = actionEnabled, onClick = { when { + // A locked recording always finishes with this + // button, draft or agent state regardless + // (review, PR #1280). + isVoiceNoteLocked -> onMicTap() + canInterrupt -> onStopGeneration() + showSend -> onSend() + else -> onMicTap() } }, - onLongClick = if (showSend && showQueue) onStopAndSend else null, - onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, + onLongClick = if (showSend && showQueue && !isVoiceNoteLocked) onStopAndSend else null, + onLongClickLabel = if (showSend && showQueue && !isVoiceNoteLocked) stopAndSendLabel else null, ).testTag( when { + isVoiceNoteLocked -> "voice_note_send_button" canInterrupt -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" @@ -573,6 +647,7 @@ fun ComposerToolbar( Crossfade( targetState = when { + isVoiceNoteLocked -> ActionGlyph.SEND canInterrupt -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP @@ -584,7 +659,12 @@ fun ComposerToolbar( ActionGlyph.SEND -> { Icon( imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), + contentDescription = + if (isVoiceNoteLocked) { + stringResource(R.string.chat_voice_send_desc) + } else { + stringResource(R.string.chat_send_desc) + }, tint = palette.onAction, ) } @@ -720,36 +800,53 @@ fun buildReasoningLabel( /** * 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. + * records a voice note, release sends it, dragging into the cancel zone + * cancels, and sliding up past [MIC_SLIDE_LOCK_DP] locks the recording + * hands-free. Short taps use the control's normal click handler for dictation, + * so touch and accessibility actions share one callback path. + * + * Distances and outcomes mirror Telegram's recorder: the full cancel distance + * is a third of the composer capped at [MAX_CANCEL_DISTANCE_DP]; releasing + * below [CANCEL_RELEASE_ALPHA] of it cancels (dragging back out before release + * un-cancels); dragging all the way cancels mid-gesture; [onSlideProgress] + * reports the 1 → 0 drag fraction so the recording strip can track and fade + * its hint; locking only arms while the gesture stays clear of the cancel zone + * ([LOCK_MIN_ALPHA]). * * 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. + * + * Once the hold phase arms, every event is consumed: the shared click handler + * otherwise also observes the stationary release that ends a hold (there is no + * drag to cancel it), and re-dispatches the tap action — the system dictation + * intent launched every time a voice note was sent (regression, #1247). */ private suspend fun PointerInputScope.micHoldHandler( isEnabled: () -> Boolean, onHoldStart: () -> Unit, onHoldEnd: () -> Unit, onHoldCancel: () -> Unit, + onHoldLock: () -> Unit, + onSlideProgress: (Float) -> Unit, + cancelDistance: Float, ) { - val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx() + val lockSlop = MIC_SLIDE_LOCK_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 - } + fun slideAlpha(position: Offset): Float = + (1f + (position.x - down.position.x) / cancelDistance).coerceIn(0f, 1f) - // Phase 1 — a finger-up before the threshold is a plain tap. + fun slidUp(position: Offset): Boolean = position.y - down.position.y < -lockSlop + + // Phase 1 — a finger-up before the threshold is a plain tap, left to + // the click handler. Dragging into the cancel zone aborts the gesture. + // Events are not consumed yet, so the tap path stays intact. var earlyUp = false var gestureAborted = false withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) { @@ -765,7 +862,7 @@ private suspend fun PointerInputScope.micHoldHandler( earlyUp = true return@withTimeoutOrNull } - if (change.isConsumed || slidAway(change.position)) { + if (change.isConsumed || slideAlpha(change.position) < CANCEL_RELEASE_ALPHA) { gestureAborted = true return@withTimeoutOrNull } @@ -779,23 +876,44 @@ private suspend fun PointerInputScope.micHoldHandler( } // Phase 2 — the threshold passed with the finger down: record until - // the finger lifts (send) or slides away (cancel). No timeout here. + // the finger lifts (send, unless dragged into the cancel zone), drags + // all the way left (cancel), or slides up (lock hands-free). onHoldStart() var send = false + var lock = false try { while (true) { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == down.id } ?: break + val alpha = slideAlpha(change.position) + onSlideProgress(alpha) if (!change.pressed) { - send = true + change.consume() + send = alpha >= CANCEL_RELEASE_ALPHA break } - if (change.isConsumed || slidAway(change.position)) { + if (change.isConsumed) { + change.consume() break } + if (alpha <= 0f) { + change.consume() + break + } + if (slidUp(change.position) && alpha >= LOCK_MIN_ALPHA) { + lock = true + change.consume() + break + } + change.consume() } } finally { - if (send) onHoldEnd() else onHoldCancel() + onSlideProgress(1f) + when { + lock -> onHoldLock() + send -> onHoldEnd() + else -> onHoldCancel() + } } } } @@ -803,5 +921,14 @@ private suspend fun PointerInputScope.micHoldHandler( /** 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 +/** Sliding this far up from the mic button locks the recording hands-free. */ +private const val MIC_SLIDE_LOCK_DP = 57 + +/** Full slide-to-cancel distance: a third of the composer (Telegram). */ +private const val MAX_CANCEL_DISTANCE_DP = 140 + +/** Releasing below this drag fraction of the cancel distance cancels. */ +private const val CANCEL_RELEASE_ALPHA = 0.45f + +/** Locking only arms while the gesture stays clear of the cancel zone. */ +private const val LOCK_MIN_ALPHA = 0.7f 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 index 1cca004a..a214b443 100644 --- 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 @@ -1,51 +1,79 @@ package com.m57.hermescontrol.ui.chat.components import android.os.SystemClock +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints 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.offset 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.material3.minimumInteractiveComponentSize 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.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import kotlinx.coroutines.delay +import kotlin.math.roundToInt /** * 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. + * voice note is being recorded. Layout mirrors Telegram's recorder: a blinking + * red dot and the elapsed timer sit on the left; "◀ Slide to cancel" (with the + * word "cancel" emphasized) sits centered to their right, gently nudging back + * and forth, and slides left and fades as the gesture drags toward cancel + * ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to + * lock, the hint is replaced by a bold tappable "CANCEL" and the action button + * submits the note. */ @Composable internal fun VoiceNoteRecordingPanel( - amplitude: State, + slideProgress: State, modifier: Modifier = Modifier, + locked: Boolean = false, + onCancel: () -> Unit = {}, ) { val palette = composerPalette() + val density = LocalDensity.current + val haptic = LocalHapticFeedback.current var elapsedSeconds by remember { mutableStateOf(0L) } LaunchedEffect(Unit) { val startedAt = SystemClock.elapsedRealtime() @@ -54,71 +82,130 @@ internal fun VoiceNoteRecordingPanel( 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) - } - } - } + // Telegram blinks the record dot on a 1200 ms cycle. + val blink = rememberInfiniteTransition(label = "voice_dot") + val dotAlpha by + blink.animateFloat( + initialValue = 1f, + targetValue = 0.35f, + animationSpec = infiniteRepeatable(tween(600, easing = LinearEasing), RepeatMode.Reverse), + label = "voice_dot_alpha", + ) + // ...and nudges the cancel hint ±6 dp while the gesture is idle (Telegram + // moves it 3 dp / 250 ms), so the slide affordance announces itself. + val nudgeDp by + blink.animateFloat( + initialValue = -6f, + targetValue = 6f, + animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing), RepeatMode.Reverse), + label = "voice_hint_nudge", + ) + val progress = slideProgress.value.coerceIn(0f, 1f) 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), - ) + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(12.dp) + .background(MaterialTheme.colorScheme.error.copy(alpha = dotAlpha), CircleShape), + ) + } + Spacer(modifier = Modifier.width(6.dp)) Text( text = formatElapsed(elapsedSeconds), style = MaterialTheme.typography.labelLarge, - color = palette.text, + fontWeight = FontWeight.Bold, + color = palette.placeholder, ) - Row( - modifier = Modifier.height(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), + BoxWithConstraints( + modifier = + Modifier + .weight(1f) + .clipToBounds(), + contentAlignment = Alignment.Center, ) { - val filler = VOICE_NOTE_BARS - levels.size - repeat(VOICE_NOTE_BARS) { index -> - val level = if (index < filler) 0f else levels[index - filler] - Box( + val containerWidthPx = with(density) { maxWidth.toPx() } + if (locked) { + Text( + text = stringResource(R.string.chat_voice_cancel).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = palette.text, modifier = Modifier - .width(3.dp) - .height((4f + 18f * level).dp) - .background(palette.placeholder, RoundedCornerShape(2.dp)), + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onCancel() + }.testTag("voice_note_cancel_button"), ) + } else { + Row( + modifier = + Modifier + .offset { + val shift = -containerWidthPx * 0.25f * (1f - progress) + val nudge = nudgeDp * density.density * progress + IntOffset((shift + nudge).roundToInt(), 0) + }.alpha(progress), + verticalAlignment = Alignment.CenterVertically, + ) { + CancelChevron(tint = palette.placeholder) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = slideToCancelText(palette), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } } } } } -/** Number of level-meter bars in the recording panel. */ -private const val VOICE_NOTE_BARS = 14 +/** "Slide to cancel" with the cancel word carrying the visual emphasis. */ +@Composable +private fun slideToCancelText(palette: ComposerPalette) = + buildAnnotatedString { + // Two resources so translators control both parts independently; the + // space between them is added here (review, PR #1280). + withStyle(SpanStyle(color = palette.placeholder)) { + append(stringResource(R.string.chat_voice_slide_to_cancel_prefix)) + append(" ") + } + withStyle(SpanStyle(color = palette.text, fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.chat_voice_slide_to_cancel_action)) + } + } + +/** The small drawn left chevron that precedes the cancel hint. */ +@Composable +private fun CancelChevron(tint: Color) { + Canvas(modifier = Modifier.size(width = 4.dp, height = 10.dp)) { + val stroke = 1.6.dp.toPx() + val midY = size.height / 2f + val path = + Path().apply { + moveTo(size.width - stroke / 2f, midY - 5.dp.toPx()) + lineTo(stroke / 2f, midY) + lineTo(size.width - stroke / 2f, midY + 5.dp.toPx()) + } + drawPath( + path = path, + color = tint, + style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} /** Formats elapsed recording time as m:ss. */ private fun formatElapsed(seconds: Long): String { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d0ff6d3..968d3120 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,12 @@ Send Listening… + Cancel + Slide up to lock recording Could not start voice recording - Slide to cancel + Send voice note + cancel + Slide to Stop generating Microphone permission is required for voice input Speech recognition is not available on this device 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 index e1387b9a..1dce6cb8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt @@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.Response import java.io.File +import java.net.ConnectException import java.net.SocketTimeoutException +import java.net.UnknownHostException class VoiceNoteRepositoryTest { private val api = mockk() @@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest { coVerify(exactly = 1) { api.transcribeAudio(any()) } } + @Test + fun transcribe_retriesOnceWhenTheConnectNeverEstablished() = + runTest { + // A connect failure means the request never reached the server, so + // one re-attempt is safe and rescues Wi-Fi/cellular handoffs that + // surface as "Network is unreachable" (device follow-up, #1247). + val file = tempVoiceFile() + var attempts = 0 + coEvery { api.transcribeAudio(any()) } coAnswers { + attempts++ + if (attempts == 1) { + throw ConnectException("failed to connect to /192.168.1.10:8000") + } + Response.success(AudioTranscriptionResponse(ok = true, transcript = " hi ")) + } + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertEquals("hi", (result as NetworkResult.Success).data) + coVerify(exactly = 2) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_givesUpAfterOneConnectRetry() = + runTest { + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } throws UnknownHostException("no dns") + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 2) { api.transcribeAudio(any()) } + } + @Test fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() = runTest {