-
Notifications
You must be signed in to change notification settings - Fork 379
Description
Which packages are you using?
stream_chat_flutter
On what platforms did you experience the issue?
Android
What version are you using?
Flutter | 3.38.3 (Dart 3.10.1)
stream_chat_flutter | 9.22.0
stream_chat | 9.22.0
stream_chat_flutter_core | ^9.0.0
stream_chat_localizations | 9.23.0
record (transitive) | 6.2.0
flutter_portal (transitive) | 1.1.4
Platform | iOS & Android
What happened?
When using the StreamMessageInput with enableVoiceRecording: true, the app becomes unresponsive if the user long-presses the voice recording button and releases without performing any slide gesture (up to lock or left to cancel). The recorder gets permanently stuck in the RecordStateRecordingHold state with no way to exit, forcing the user to restart the app.
Steps to Reproduce
- Use
StreamMessageInputwithenableVoiceRecording: true - Long-press the voice recording button (recording starts)
- Do not slide the finger in any direction (no lock or cancel gesture)
- Release the finger normally
Expected Behavior
The voice recording should either:
- Finish and send the recording, OR
- Cancel gracefully
The app should remain responsive in all cases.
Actual Behavior
The app appears frozen/hung. The recording-hold UI (timer + "slide to cancel" indicator) remains on screen permanently. The user cannot:
- Type in the message input (it's replaced by the recording overlay)
- Cancel the recording (no cancel button in hold state; slide gesture requires an active touch)
- Interact with the chat
The only recovery is to kill and restart the app.
Root Cause Analysis
There is a race condition between the async startRecord() method in StreamAudioRecorderController and the synchronous gesture callbacks in StreamAudioRecorderButton.
In StreamAudioRecorderButton.build() (stream_audio_recorder.dart), the isRecording flag is captured as a local variable from recordState at build time and used inside GestureDetector closures:
@override
Widget build(BuildContext context) {
final isRecording = recordState is! RecordStateIdle; // captured at build time
final isLocked = isRecording && recordState is! RecordStateRecordingHold;
return GestureDetector(
onLongPressStart: (_) {
if (isRecording) return;
feedback.onRecordStart(context);
return onRecordStart?.call(); // calls startRecord() — async, NOT awaited
},
onLongPressEnd: (_) {
if (!isRecording || isLocked) return; // uses stale isRecording = false
feedback.onRecordFinish(context);
return onRecordFinish?.call();
},
// ...
);
}In StreamAudioRecorderController.startRecord() (audio_recorder_controller.dart), three sequential await operations occur before the state transitions from RecordStateIdle to RecordStateRecordingHold:
Future<void> startRecord() async {
if (value case RecordStateIdle()) {
final hasPermission = await _recorder.hasPermission(); // platform channel call
if (!hasPermission) return;
final tempPath = await _getOutputFilePath(config.encoder); // platform channel call (getTemporaryDirectory)
await _recorder.start(config, path: tempPath); // platform channel call
_startDurationTimer();
value = const RecordStateRecordingHold(); // state update happens LAST
}
}The race unfolds as follows:
onLongPressStartfires → callsstartRecord()but does NOTawaitit (callback isVoidCallback?, theFutureis discarded)- While the three async platform channel calls are in flight (~50–150ms), the state is still
RecordStateIdleandisRecordingis stillfalsein the closure - User releases finger →
onLongPressEndfires with the stale closure whereisRecording = false - The guard
if (!isRecording || isLocked) return;triggers →onRecordFinishis never called startRecord()eventually completes → state becomesRecordStateRecordingHold→ widget rebuilds showing the recording-hold UI- The recorder is now permanently stuck — no active touch to slide-cancel, no cancel button in hold state
Even if the GestureDetector updates its recognizer callbacks on rebuild (via _syncAll in _RawGestureDetectorState.didUpdateWidget), the rebuild happens in the next frame — too late if the finger was already lifted.
A secondary variant of this race: onLongPressEnd fires after the rebuild (with isRecording = true), calling finishRecord(). But if startRecord() hasn't fully completed yet, finishRecord() finds value is still RecordStateIdle, matches neither the RecordStateStopped nor RecordStateRecording case, and returns null. The subsequent cancelRecord(discardTrack: false) also finds RecordStateIdle and does nothing. Then startRecord() completes, leaving the state stuck.
Suggested Fix
The StreamAudioRecorderButton should not rely on build-time closure-captured booleans for a gesture that spans an async state transition. Possible approaches:
- Make
StreamAudioRecorderButtonstateful and readrecordStatedirectly in the callbacks (not via closure capture), or accept the controller itself and read.valueat callback time - Add a
Completer/flag inStreamAudioRecorderController.startRecord()so thatonLongPressEndcan detect a pending start and either wait for it or cancel it - Add a timeout/fallback in
RecordStateRecordingHold— if no drag or lock event occurs within N seconds, auto-cancel
Minimal Reproduction Code
StreamMessageInput(
enableVoiceRecording: true,
messageInputController: controller,
)Long-press the mic button, hold briefly (~0.5–1s), and release without sliding. The shorter the hold after the long-press is recognized, the more reliably this reproduces (since startRecord() has less time to complete).
- I agree to follow this project's Code of Conduct