feat(#1247): slide-up lock + release/network fixes for voice notes (#1280)

This commit is contained in:
Nikhil Nair
2026-09-26 18:48:49 +03:00
committed by GitHub
parent 0bc0117c89
commit 2084f9083d
10 changed files with 1108 additions and 355 deletions
@@ -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<ComponentActivity>()
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)
}
}
}
@@ -95,9 +95,12 @@ object ApiClient {
* the shared 30s client, and a re-submitted POST would repeat expensive * the shared 30s client, and a re-submitted POST would repeat expensive
* provider work after the request may already have reached the server * provider work after the request may already have reached the server
* (the repository also passes `retries = 0`). The returned service reuses * (the repository also passes `retries = 0`). The returned service reuses
* the shared connection pool and the auth/profile interceptor stack, and * the shared connection pool and the auth/profile interceptor stack. It
* applies [readTimeoutMs] to reads and writes with transport retries * applies [readTimeoutMs] to reads and writes and keeps transport-level
* disabled (review, PR #1250). * 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 { fun transcriptionService(readTimeoutMs: Long): HermesApiService {
val client = val client =
@@ -109,7 +112,16 @@ object ApiClient {
.authenticator(TokenRefreshAuthenticator) .authenticator(TokenRefreshAuthenticator)
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(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() .build()
return Retrofit return Retrofit
.Builder() .Builder()
@@ -1,13 +1,17 @@
package com.m57.hermescontrol.data.repository package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest 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.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.delay
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.ConnectException
import java.net.UnknownHostException
import java.util.Base64 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`: * - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
* re-submitting after a timeout or 5xx would repeat expensive provider * re-submitting after a timeout or 5xx would repeat expensive provider
* work although the request may already have reached the server. * 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). * 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 dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
val result = val service = serviceProvider(transcriptionTimeoutMs(dataUrl))
safeApiCall(retries = 0) { val result = requestTranscription(service, dataUrl)
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio( // One bounded re-attempt, and only for connection-never-established
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), // 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 -> { is NetworkResult.Success -> {
NetworkResult.Success( NetworkResult.Success(
result.data finalResult.data
?.transcript ?.transcript
.orEmpty() .orEmpty()
.trim(), .trim(),
@@ -71,11 +86,26 @@ open class VoiceNoteRepository(
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
result finalResult
} }
} }
} }
private suspend fun requestTranscription(
service: HermesApiService,
dataUrl: String,
): NetworkResult<AudioTranscriptionResponse> =
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 { companion object {
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */ /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
const val MIN_REQUEST_TIMEOUT_MS = 180_000L const val MIN_REQUEST_TIMEOUT_MS = 180_000L
@@ -85,6 +115,9 @@ open class VoiceNoteRepository(
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L 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 * Timeout for the blocking STT endpoint, mirroring the desktop's
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
@@ -908,9 +908,10 @@ fun ChatScreen(
onMicHoldStart = mediaLaunchers.onMicHoldStart, onMicHoldStart = mediaLaunchers.onMicHoldStart,
onMicHoldEnd = mediaLaunchers.onMicHoldEnd, onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
onMicHoldCancel = mediaLaunchers.onMicHoldCancel, onMicHoldCancel = mediaLaunchers.onMicHoldCancel,
onMicLock = mediaLaunchers.onMicLock,
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
isRecordingVoice = mediaLaunchers.isRecordingVoice, isRecordingVoice = mediaLaunchers.isRecordingVoice,
voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude, isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
onStopGeneration = { viewModel.interruptSession() }, onStopGeneration = { viewModel.interruptSession() },
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
isMainTurnBusy = state.isMainTurnBusy, isMainTurnBusy = state.isMainTurnBusy,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
@@ -9,6 +10,7 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -47,9 +53,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -113,8 +124,9 @@ fun ChatInputBar(
onMicHoldStart: () -> Unit = {}, onMicHoldStart: () -> Unit = {},
onMicHoldEnd: () -> Unit = {}, onMicHoldEnd: () -> Unit = {},
onMicHoldCancel: () -> Unit = {}, onMicHoldCancel: () -> Unit = {},
onMicLock: () -> Unit = {},
isVoiceNoteLocked: Boolean = false,
isRecordingVoice: Boolean = false, isRecordingVoice: Boolean = false,
voiceNoteAmplitude: State<Float> = remember { mutableStateOf(0f) },
onStopGeneration: () -> Unit = {}, onStopGeneration: () -> Unit = {},
) { ) {
// Allow sending while the agent is mid-turn or awaiting approval: the // Allow sending while the agent is mid-turn or awaiting approval: the
@@ -130,9 +142,67 @@ fun ChatInputBar(
// Attachment tray state // Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) } 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() val palette = composerPalette()
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
Box {
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
enter = slideInVertically(initialOffsetY = { it }), enter = slideInVertically(initialOffsetY = { it }),
@@ -156,8 +226,10 @@ fun ChatInputBar(
// also enforced at dispatch time (issue #576, deliverable #3). // also enforced at dispatch time (issue #576, deliverable #3).
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
val commandNames = val commandNames =
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")) (
.distinct() commandCatalog.pairs.map { it[0] } +
listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")
).distinct()
.filter { it.lowercase() !in hiddenSlashDisplay } .filter { it.lowercase() !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
@@ -273,13 +345,18 @@ fun ChatInputBar(
val ambientLayoutDirection = LocalLayoutDirection.current val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection = val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) { remember(inputFieldValue.text, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) BidiUtils.resolveLayoutDirection(
inputFieldValue.text,
fallback = ambientLayoutDirection,
)
} }
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
if (isRecordingVoice) { if (isRecordingVoice) {
VoiceNoteRecordingPanel( VoiceNoteRecordingPanel(
amplitude = voiceNoteAmplitude, slideProgress = voiceSlideProgress,
locked = isVoiceNoteLocked,
onCancel = onMicHoldCancel,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} else { } else {
@@ -292,7 +369,12 @@ fun ChatInputBar(
.weight(1f) .weight(1f)
.heightIn(min = 42.dp, max = 200.dp) .heightIn(min = 42.dp, max = 200.dp)
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.testTag("chat_input"), .focusRequester(inputFocusRequester)
.onFocusChanged { focusState ->
if (!isRecordingVoice) {
inputWasFocused = focusState.isFocused
}
}.testTag("chat_input"),
enabled = isConnected, enabled = isConnected,
textStyle = textStyle =
MaterialTheme.typography.bodyLarge.copy( MaterialTheme.typography.bodyLarge.copy(
@@ -321,7 +403,8 @@ fun ChatInputBar(
Text( Text(
text = placeholderText, text = placeholderText,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, textAlign =
if (isInputRtl) TextAlign.Right else TextAlign.Left,
color = palette.placeholder, color = palette.placeholder,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -356,9 +439,12 @@ fun ChatInputBar(
onModelTap = onModelTap, onModelTap = onModelTap,
onReasoningSelected = onReasoningTap, onReasoningSelected = onReasoningTap,
onMicTap = onMicTap, onMicTap = onMicTap,
onMicHoldStart = onMicHoldStart, onMicHoldStart = handleMicHoldStart,
onMicHoldEnd = onMicHoldEnd, onMicHoldEnd = handleMicHoldEnd,
onMicHoldCancel = onMicHoldCancel, onMicHoldCancel = handleMicHoldCancel,
onMicLock = onMicLock,
isVoiceNoteLocked = isVoiceNoteLocked,
onSlideProgress = { voiceSlideProgress.value = it },
modifier = Modifier.testTag("chat_composer_toolbar"), modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning, canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning, supportsReasoning = supportsReasoning,
@@ -382,7 +468,81 @@ fun ChatInputBar(
} }
} }
} }
// 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 * Reusable attachment chip composable for showing a pending attachment
@@ -14,7 +14,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -40,7 +39,7 @@ import java.util.Locale
class ChatMediaLaunchers( class ChatMediaLaunchers(
val isListening: Boolean, val isListening: Boolean,
val isRecordingVoice: Boolean, val isRecordingVoice: Boolean,
val voiceNoteAmplitude: State<Float>, val isVoiceNoteLocked: Boolean,
val onMicTap: () -> Unit, val onMicTap: () -> Unit,
val onCameraTap: () -> Unit, val onCameraTap: () -> Unit,
val onImageTap: () -> Unit, val onImageTap: () -> Unit,
@@ -48,6 +47,7 @@ class ChatMediaLaunchers(
val onMicHoldStart: () -> Unit = {}, val onMicHoldStart: () -> Unit = {},
val onMicHoldEnd: () -> Unit = {}, val onMicHoldEnd: () -> Unit = {},
val onMicHoldCancel: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {},
val onMicLock: () -> Unit = {},
) )
@Composable @Composable
@@ -85,28 +85,14 @@ fun rememberChatMediaLaunchers(
val voiceNoteRecorder = remember { VoiceNoteRecorder(context) } val voiceNoteRecorder = remember { VoiceNoteRecorder(context) }
var isRecordingVoice by remember { mutableStateOf(false) } var isRecordingVoice by remember { mutableStateOf(false) }
// Live mic level for the recording panel — rises fast, decays slowly so // Locked = the recording continues after the finger lifts (slide up
// the meter reads as voice activity instead of flicker. // during the hold); the action button then submits it.
val voiceNoteAmplitude = remember { mutableStateOf(0f) } var isVoiceNoteLocked by remember { mutableStateOf(false) }
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() { fun finishVoiceRecording() {
val recordedFile = voiceNoteRecorder.stop() val recordedFile = voiceNoteRecorder.stop()
isRecordingVoice = false isRecordingVoice = false
isVoiceNoteLocked = false
if (recordedFile != null) { if (recordedFile != null) {
currentOnVoiceNoteRecorded(recordedFile) currentOnVoiceNoteRecorded(recordedFile)
} }
@@ -264,6 +250,9 @@ fun rememberChatMediaLaunchers(
if (isTranscribingVoiceNote) { if (isTranscribingVoiceNote) {
// Single-flight: a server transcription owns the voice pipeline // Single-flight: a server transcription owns the voice pipeline
// until it lands (review, PR #1250). // until it lands (review, PR #1250).
} else if (isVoiceNoteLocked) {
// The locked recording's action button submits the note.
finishVoiceRecording()
} else if (isRecordingVoice) { } else if (isRecordingVoice) {
// A tap while recording discards the in-flight voice note. // A tap while recording discards the in-flight voice note.
voiceNoteRecorder.cancel() voiceNoteRecorder.cancel()
@@ -334,6 +323,15 @@ fun rememberChatMediaLaunchers(
voiceNoteRecorder.cancel() voiceNoteRecorder.cancel()
} }
isRecordingVoice = false 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 = { val onCameraTap: () -> Unit = {
@@ -369,11 +367,11 @@ fun rememberChatMediaLaunchers(
} }
} }
return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) { return remember(isListening, isRecordingVoice, isVoiceNoteLocked, isTranscribingVoiceNote) {
ChatMediaLaunchers( ChatMediaLaunchers(
isListening = isListening || isRecordingVoice, isListening = isListening || isRecordingVoice,
isRecordingVoice = isRecordingVoice, isRecordingVoice = isRecordingVoice,
voiceNoteAmplitude = voiceNoteAmplitude, isVoiceNoteLocked = isVoiceNoteLocked,
onMicTap = onMicTap, onMicTap = onMicTap,
onCameraTap = onCameraTap, onCameraTap = onCameraTap,
onImageTap = onImageTap, onImageTap = onImageTap,
@@ -381,6 +379,7 @@ fun rememberChatMediaLaunchers(
onMicHoldStart = onMicHoldStart, onMicHoldStart = onMicHoldStart,
onMicHoldEnd = onMicHoldEnd, onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel, onMicHoldCancel = onMicHoldCancel,
onMicLock = onMicLock,
) )
} }
} }
@@ -61,8 +61,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset 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.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput 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.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.CustomAccessibilityAction
@@ -127,6 +131,9 @@ fun ComposerToolbar(
onMicHoldStart: () -> Unit = {}, onMicHoldStart: () -> Unit = {},
onMicHoldEnd: () -> Unit = {}, onMicHoldEnd: () -> Unit = {},
onMicHoldCancel: () -> Unit = {}, onMicHoldCancel: () -> Unit = {},
onMicLock: () -> Unit = {},
isVoiceNoteLocked: Boolean = false,
onSlideProgress: (Float) -> Unit = {},
onStopGeneration: () -> Unit = {}, onStopGeneration: () -> Unit = {},
) { ) {
var showReasoningMenu by remember { mutableStateOf(false) } var showReasoningMenu by remember { mutableStateOf(false) }
@@ -134,36 +141,74 @@ fun ComposerToolbar(
val reasoningDisabledForModel = supportsReasoning == false val reasoningDisabledForModel = supportsReasoning == false
val canDisable = canDisableReasoning val canDisable = canDisableReasoning
// Telegram-style voice notes: hold to record, release to send, slide away // Telegram-style voice notes: hold to record, release to send, slide left
// to cancel. Both mic controls run the same single pointer loop // to cancel, slide up to lock hands-free. Both mic controls run the same
// (micHoldHandler), which owns the whole press until finger-up — so the // single pointer loop (micHoldHandler), which owns the whole press until
// release that submits the note can never be lost between recompositions. // finger-up — so the release that submits the note can never be lost
// between recompositions.
val currentIsConnected = rememberUpdatedState(isConnected) val currentIsConnected = rememberUpdatedState(isConnected)
val currentShowSend = rememberUpdatedState(showSend) val currentShowSend = rememberUpdatedState(showSend)
val currentCanInterrupt = rememberUpdatedState(canInterrupt) val currentCanInterrupt = rememberUpdatedState(canInterrupt)
val currentIsVoiceNoteLocked = rememberUpdatedState(isVoiceNoteLocked)
val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart) val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart)
val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd) val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd)
val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel) 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 = val flatMicGesture =
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
micHoldHandler( micHoldHandler(
isEnabled = { isEnabled = {
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value &&
!currentIsVoiceNoteLocked.value
}, },
onHoldStart = { currentOnMicHoldStart.value() }, onHoldStart = { currentOnMicHoldStart.value() },
onHoldEnd = { currentOnMicHoldEnd.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 = val actionMicGesture =
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
micHoldHandler( micHoldHandler(
isEnabled = { isEnabled = {
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value &&
!currentIsVoiceNoteLocked.value
}, },
onHoldStart = { currentOnMicHoldStart.value() }, onHoldStart = { currentOnMicHoldStart.value() },
onHoldEnd = { currentOnMicHoldEnd.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 { } else {
FilledIconButton( // Plain Box, not FilledIconButton: the hold gesture must sit
onClick = onMicTap, // innermost in the pointer-modifier chain so it consumes the
enabled = isConnected, // release before the tap handler — impossible while the button
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), // 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 =
Modifier Modifier
.size(ControlSize) .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), .then(flatMicGesture),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
contentDescription = if (isListening) "Stop listening" else "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 // Action button — send when a send is possible, mic / stop otherwise
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) 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( Box(
modifier = modifier =
Modifier Modifier
@@ -540,7 +606,7 @@ fun ComposerToolbar(
palette.action palette.action
}, },
).semantics { ).semantics {
if (showSend && showQueue && canSend) { if (showSend && showQueue && canSend && !isVoiceNoteLocked) {
customActions = customActions =
listOf( listOf(
CustomAccessibilityAction(stopAndSendLabel) { CustomAccessibilityAction(stopAndSendLabel) {
@@ -553,15 +619,23 @@ fun ComposerToolbar(
enabled = actionEnabled, enabled = actionEnabled,
onClick = { onClick = {
when { when {
// A locked recording always finishes with this
// button, draft or agent state regardless
// (review, PR #1280).
isVoiceNoteLocked -> onMicTap()
canInterrupt -> onStopGeneration() canInterrupt -> onStopGeneration()
showSend -> onSend() showSend -> onSend()
else -> onMicTap() else -> onMicTap()
} }
}, },
onLongClick = if (showSend && showQueue) onStopAndSend else null, onLongClick = if (showSend && showQueue && !isVoiceNoteLocked) onStopAndSend else null,
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, onLongClickLabel = if (showSend && showQueue && !isVoiceNoteLocked) stopAndSendLabel else null,
).testTag( ).testTag(
when { when {
isVoiceNoteLocked -> "voice_note_send_button"
canInterrupt -> "stop_button" canInterrupt -> "stop_button"
showSend -> "send_button" showSend -> "send_button"
isListening -> "mic_stop_button" isListening -> "mic_stop_button"
@@ -573,6 +647,7 @@ fun ComposerToolbar(
Crossfade( Crossfade(
targetState = targetState =
when { when {
isVoiceNoteLocked -> ActionGlyph.SEND
canInterrupt -> ActionGlyph.STOP canInterrupt -> ActionGlyph.STOP
showSend -> ActionGlyph.SEND showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP isListening -> ActionGlyph.STOP
@@ -584,7 +659,12 @@ fun ComposerToolbar(
ActionGlyph.SEND -> { ActionGlyph.SEND -> {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, 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, tint = palette.onAction,
) )
} }
@@ -720,36 +800,53 @@ fun buildReasoningLabel(
/** /**
* Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS] * 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 * records a voice note, release sends it, dragging into the cancel zone
* the button cancels instead of sending. Short taps use the control's normal * cancels, and sliding up past [MIC_SLIDE_LOCK_DP] locks the recording
* click handler for dictation, so touch and accessibility actions share one * hands-free. Short taps use the control's normal click handler for dictation,
* callback path. * 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 * 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 * that submits can never be lost to a recomposition between press and
* finger-up — an earlier interaction-source based approach could lose it 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. * 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( private suspend fun PointerInputScope.micHoldHandler(
isEnabled: () -> Boolean, isEnabled: () -> Boolean,
onHoldStart: () -> Unit, onHoldStart: () -> Unit,
onHoldEnd: () -> Unit, onHoldEnd: () -> Unit,
onHoldCancel: () -> 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 { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
if (!isEnabled()) { if (!isEnabled()) {
return@awaitEachGesture return@awaitEachGesture
} }
fun slidAway(position: Offset): Boolean { fun slideAlpha(position: Offset): Float =
val dx = position.x - down.position.x (1f + (position.x - down.position.x) / cancelDistance).coerceIn(0f, 1f)
val dy = position.y - down.position.y
return dx < -cancelSlop || dy < -cancelSlop
}
// 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 earlyUp = false
var gestureAborted = false var gestureAborted = false
withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) { withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) {
@@ -765,7 +862,7 @@ private suspend fun PointerInputScope.micHoldHandler(
earlyUp = true earlyUp = true
return@withTimeoutOrNull return@withTimeoutOrNull
} }
if (change.isConsumed || slidAway(change.position)) { if (change.isConsumed || slideAlpha(change.position) < CANCEL_RELEASE_ALPHA) {
gestureAborted = true gestureAborted = true
return@withTimeoutOrNull return@withTimeoutOrNull
} }
@@ -779,23 +876,44 @@ private suspend fun PointerInputScope.micHoldHandler(
} }
// Phase 2 — the threshold passed with the finger down: record until // 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() onHoldStart()
var send = false var send = false
var lock = false
try { try {
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break val change = event.changes.firstOrNull { it.id == down.id } ?: break
val alpha = slideAlpha(change.position)
onSlideProgress(alpha)
if (!change.pressed) { if (!change.pressed) {
send = true change.consume()
send = alpha >= CANCEL_RELEASE_ALPHA
break break
} }
if (change.isConsumed || slidAway(change.position)) { if (change.isConsumed) {
change.consume()
break break
} }
if (alpha <= 0f) {
change.consume()
break
}
if (slidUp(change.position) && alpha >= LOCK_MIN_ALPHA) {
lock = true
change.consume()
break
}
change.consume()
} }
} finally { } 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. */ /** A press shorter than this is a tap; longer arms voice-note recording. */
private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L
/** Sliding this far from the mic button (left or up) cancels the recording. */ /** Sliding this far up from the mic button locks the recording hands-free. */
private const val MIC_SLIDE_CANCEL_DP = 64 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
@@ -1,51 +1,79 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import android.os.SystemClock 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.testTag
import androidx.compose.ui.res.stringResource 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 androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt
/** /**
* Telegram-style recording strip shown in place of the input field while a * 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 * voice note is being recorded. Layout mirrors Telegram's recorder: a blinking
* a live mic level meter so the press and the captured audio are both visible * red dot and the elapsed timer sit on the left; "◀ Slide to cancel" (with the
* even when the mic button itself is under the thumb. * 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 @Composable
internal fun VoiceNoteRecordingPanel( internal fun VoiceNoteRecordingPanel(
amplitude: State<Float>, slideProgress: State<Float>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
locked: Boolean = false,
onCancel: () -> Unit = {},
) { ) {
val palette = composerPalette() val palette = composerPalette()
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
var elapsedSeconds by remember { mutableStateOf(0L) } var elapsedSeconds by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val startedAt = SystemClock.elapsedRealtime() val startedAt = SystemClock.elapsedRealtime()
@@ -54,71 +82,130 @@ internal fun VoiceNoteRecordingPanel(
delay(200) delay(200)
} }
} }
val levels = remember { mutableStateListOf<Float>() } // Telegram blinks the record dot on a 1200 ms cycle.
LaunchedEffect(Unit) { val blink = rememberInfiniteTransition(label = "voice_dot")
snapshotFlow { amplitude.value }.collect { level -> val dotAlpha by
levels.add(level) blink.animateFloat(
while (levels.size > VOICE_NOTE_BARS) { initialValue = 1f,
levels.removeAt(0) 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( Row(
modifier = modifier =
modifier modifier
.heightIn(min = 42.dp) .heightIn(min = 42.dp)
.testTag("voice_note_recording_panel"), .testTag("voice_note_recording_panel"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Icon( Box(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, modifier = Modifier.size(28.dp),
// Decorative: the adjacent "Slide to cancel" text carries the contentAlignment = Alignment.Center,
// 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( Box(
modifier = modifier =
Modifier Modifier
.size(8.dp) .size(12.dp)
.background(MaterialTheme.colorScheme.error, CircleShape), .background(MaterialTheme.colorScheme.error.copy(alpha = dotAlpha), CircleShape),
) )
}
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = formatElapsed(elapsedSeconds), text = formatElapsed(elapsedSeconds),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = palette.text, fontWeight = FontWeight.Bold,
color = palette.placeholder,
) )
Row( BoxWithConstraints(
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 =
Modifier Modifier
.width(3.dp) .weight(1f)
.height((4f + 18f * level).dp) .clipToBounds(),
.background(palette.placeholder, RoundedCornerShape(2.dp)), contentAlignment = Alignment.Center,
) {
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
.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. */ /** "Slide to cancel" with the cancel word carrying the visual emphasis. */
private const val VOICE_NOTE_BARS = 14 @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. */ /** Formats elapsed recording time as m:ss. */
private fun formatElapsed(seconds: Long): String { private fun formatElapsed(seconds: Long): String {
+5 -1
View File
@@ -4,8 +4,12 @@
<!-- Chat --> <!-- Chat -->
<string name="chat_send_desc">Send</string> <string name="chat_send_desc">Send</string>
<string name="chat_mic_listening">Listening…</string> <string name="chat_mic_listening">Listening…</string>
<string name="chat_voice_cancel">Cancel</string>
<string name="chat_voice_lock_hint">Slide up to lock recording</string>
<string name="chat_voice_record_failed">Could not start voice recording</string> <string name="chat_voice_record_failed">Could not start voice recording</string>
<string name="chat_voice_slide_to_cancel">Slide to cancel</string> <string name="chat_voice_send_desc">Send voice note</string>
<string name="chat_voice_slide_to_cancel_action">cancel</string>
<string name="chat_voice_slide_to_cancel_prefix">Slide to</string>
<string name="chat_voice_stop_generating">Stop generating</string> <string name="chat_voice_stop_generating">Stop generating</string>
<string name="stt_permission_denied">Microphone permission is required for voice input</string> <string name="stt_permission_denied">Microphone permission is required for voice input</string>
<string name="stt_not_available">Speech recognition is not available on this device</string> <string name="stt_not_available">Speech recognition is not available on this device</string>
@@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import retrofit2.Response import retrofit2.Response
import java.io.File import java.io.File
import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException
class VoiceNoteRepositoryTest { class VoiceNoteRepositoryTest {
private val api = mockk<HermesApiService>() private val api = mockk<HermesApiService>()
@@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest {
coVerify(exactly = 1) { api.transcribeAudio(any()) } 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 @Test
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() = fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
runTest { runTest {