fix: зависание записи ГС — race condition в startVoiceRecording + утечка isVoiceRecordTransitioning

Root cause 1: startVoiceRecording() проверял только isVoiceRecording,
но isVoiceRecording=true ставился через 192ms в scope.launch. При быстром
двойном тапе два MediaRecorder создавались, первый терялся (утечка).
Фикс: добавлен guard на isVoiceRecordTransitioning и voiceRecorder!=null.

Root cause 2: isVoiceRecordTransitioning=true ставился перед scope.launch,
но если launch крашился или composable disposed, transitioning навсегда
оставался true — gesture guard блокировал все записи до перезапуска.
Фикс: try/catch в launch + reset в DisposableEffect.

Root cause 3: DisposableEffect проверял только isVoiceRecording, но не
voiceRecorder!=null — если recorder создан но isVoiceRecording ещё false,
recorder не освобождался при dispose.
Фикс: проверка voiceRecorder!=null в dispose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 14:17:03 +05:00
parent 5c02ff6fd3
commit b57e48fe20

View File

@@ -1180,7 +1180,7 @@ fun MessageInputBar(
} }
fun startVoiceRecording() { fun startVoiceRecording() {
if (isVoiceRecording) return if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return
inputJumpLog( inputJumpLog(
"startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " +
"emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx"
@@ -1227,20 +1227,25 @@ fun MessageInputBar(
) )
scope.launch { scope.launch {
repeat(12) { try {
if (!isKeyboardVisible && !coordinator.isEmojiBoxVisible) return@repeat repeat(12) {
delay(16) if (!isKeyboardVisible && !coordinator.isEmojiBoxVisible) return@repeat
delay(16)
}
isVoiceRecording = true
isVoiceRecordTransitioning = false
if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) {
setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started")
}
inputJumpLog(
"startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
"emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " +
"panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx"
)
} catch (e: Exception) {
isVoiceRecordTransitioning = false
inputJumpLog("startVoiceRecording launch failed: ${e.message}")
} }
isVoiceRecording = true
isVoiceRecordTransitioning = false
if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) {
setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started")
}
inputJumpLog(
"startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
"emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " +
"panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx"
)
} }
} catch (_: Exception) { } catch (_: Exception) {
isVoiceRecordTransitioning = false isVoiceRecordTransitioning = false
@@ -1420,8 +1425,9 @@ fun MessageInputBar(
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
pendingRecordAfterPermission = false pendingRecordAfterPermission = false
isVoiceRecordTransitioning = false
resetGestureState() resetGestureState()
if (isVoiceRecording) { if (isVoiceRecording || voiceRecorder != null) {
stopVoiceRecording(send = false) stopVoiceRecording(send = false)
} else { } else {
setRecordUiState(RecordUiState.IDLE, "dispose") setRecordUiState(RecordUiState.IDLE, "dispose")