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

This commit is contained in:
Nikhil Nair authored and GitHub committed 2026-09-26 18:48:49 +03:00
1 parent 0bc0117c89
commit 2084f9083d
10 files changed
+1108 -355

No files matched your search

@@ -95,9 +95,12 @@ object ApiClient {
* the shared 30s client, and a re-submitted POST would repeat expensive
* provider work after the request may already have reached the server
* (the repository also passes `retries = 0`). The returned service reuses
* the shared connection pool and the auth/profile interceptor stack, and
* applies [readTimeoutMs] to reads and writes with transport retries
* disabled (review, PR #1250).
* the shared connection pool and the auth/profile interceptor stack. It
* applies [readTimeoutMs] to reads and writes and keeps transport-level
* retries on: they re-dial and can resend depending on when a failure
* struck, so they do not guarantee the absence of duplicate provider
* work — the risk is small, not zero. Application-level retry stays off
* (review, PR #1250 / #1280).
*/
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
val client =
@@ -109,7 +112,16 @@ object ApiClient {
.authenticator(TokenRefreshAuthenticator)
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(false)
// Transport-level stale-socket rescue stays ON: the shared pool
// keeps connections far longer than the dashboard's keep-alive,
// so the first STT call after an idle spell can ride a socket
// the server just closed ("unexpected end of stream"). OkHttp
// re-dials and can resend depending on when the failure struck,
// and it may retry across routes — duplicate provider work is
// possible, though rare. Timeouts and 5xx are never replayed
// (safeApiCall still runs with retries = 0). Risk small, not
// zero (review, PR #1280).
.retryOnConnectionFailure(true)
.build()
return Retrofit
.Builder()
@@ -1,13 +1,17 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.delay
import java.io.File
import java.io.IOException
import java.net.ConnectException
import java.net.UnknownHostException
import java.util.Base64
/**
@@ -33,6 +37,10 @@ const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
* - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
* re-submitting after a timeout or 5xx would repeat expensive provider
* work although the request may already have reached the server.
* - Connect-never-established failures (unreachable route, DNS blip — the
* usual suspects across Wi-Fi/cellular switches) get ONE delayed
* re-attempt: that request never reached the server, so no provider work
* is duplicated (device follow-up, #1247).
*
* Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
*/
@@ -54,16 +62,23 @@ open class VoiceNoteRepository(
)
}
val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
val result =
safeApiCall(retries = 0) {
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
)
val service = serviceProvider(transcriptionTimeoutMs(dataUrl))
val result = requestTranscription(service, dataUrl)
// One bounded re-attempt, and only for connection-never-established
// failures — the one class where the request provably never reached
// the server, so no provider work is duplicated (device follow-up,
// #1247).
val finalResult =
if (result is NetworkResult.Failure && isConnectNeverEstablished(result.error)) {
delay(CONNECT_RETRY_DELAY_MS)
requestTranscription(service, dataUrl)
} else {
result
}
return when (result) {
return when (finalResult) {
is NetworkResult.Success -> {
NetworkResult.Success(
result.data
finalResult.data
?.transcript
.orEmpty()
.trim(),
@@ -71,11 +86,26 @@ open class VoiceNoteRepository(
}
is NetworkResult.Failure -> {
result
finalResult
}
}
}
private suspend fun requestTranscription(
service: HermesApiService,
dataUrl: String,
): NetworkResult<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 {
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
const val MIN_REQUEST_TIMEOUT_MS = 180_000L
@@ -85,6 +115,9 @@ open class VoiceNoteRepository(
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
/** One re-attempt window for connection-never-established failures. */
private const val CONNECT_RETRY_DELAY_MS = 750L
/**
* Timeout for the blocking STT endpoint, mirroring the desktop's
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char