This commit is contained in:
+294
@@ -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
|
||||||
|
|||||||
+141
-54
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user