Skip to content

App freezes when releasing voice recording long-press without sliding gesture #2536

@Youssefguba

Description

@Youssefguba

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

  1. Use StreamMessageInput with enableVoiceRecording: true
  2. Long-press the voice recording button (recording starts)
  3. Do not slide the finger in any direction (no lock or cancel gesture)
  4. 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:

  1. onLongPressStart fires → calls startRecord() but does NOT await it (callback is VoidCallback?, the Future is discarded)
  2. While the three async platform channel calls are in flight (~50–150ms), the state is still RecordStateIdle and isRecording is still false in the closure
  3. User releases finger → onLongPressEnd fires with the stale closure where isRecording = false
  4. The guard if (!isRecording || isLocked) return; triggers → onRecordFinish is never called
  5. startRecord() eventually completes → state becomes RecordStateRecordingHold → widget rebuilds showing the recording-hold UI
  6. 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:

  1. Make StreamAudioRecorderButton stateful and read recordState directly in the callbacks (not via closure capture), or accept the controller itself and read .value at callback time
  2. Add a Completer/flag in StreamAudioRecorderController.startRecord() so that onLongPressEnd can detect a pending start and either wait for it or cancel it
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions