Fix crash on rapid double-clicking video record button#541
Fix crash on rapid double-clicking video record button#541davidjiagoogle wants to merge 1 commit into
Conversation
Add CaptureButtonUiState.Enabled.Recording.Pending state to represent the window when video recording is starting. Map VideoRecordingState.Starting to Pending instead of Idle, preventing rapid double-clicks from sending duplicate StartRecordingEvents. Additionally, update CameraSession to log a warning instead of throwing an unhandled IllegalStateException when receiving duplicate StartRecordingEvents during active recording. Bug: 535645067 Test: ./gradlew :ui:uistateadapter:capture:test
There was a problem hiding this comment.
Code Review
This pull request introduces a new Pending state to CaptureButtonUiState.Enabled.Recording to represent when a video recording request has been sent but is still initializing. This state is mapped from VideoRecordingState.Starting in the adapter and handled in the capture button components and tests. Additionally, CameraSession now logs a warning instead of throwing an exception if a start recording event is received while a recording is already in progress. Feedback was provided regarding a potential bug where releasing the capture button during the Pending state would ignore the key-up event, causing the recording to continue indefinitely; a code suggestion was provided to handle this state in onKeyUp.
| } | ||
|
|
||
| CaptureButtonUiState.Enabled.Recording.LockedRecording -> onStopRecording() | ||
| CaptureButtonUiState.Enabled.Recording.Pending, |
There was a problem hiding this comment.
If a user starts a video recording via a long-press but releases the button quickly while the UI state is still in CaptureButtonUiState.Enabled.Recording.Pending (before transitioning to PressedRecording), the release event is ignored. This is because the isLongPressing block in onKeyUp (lines 251-258) only checks for PressedRecording to trigger onStopRecording(). As a result, the recording will continue indefinitely with the UI stuck in the pressed state even though the user has released their finger.\n\nTo fix this, please update the check in onKeyUp to also handle the Pending state:\n\nkotlin\nif (!isLocked &&\n (currentUiState.value is CaptureButtonUiState.Enabled.Recording.PressedRecording ||\n currentUiState.value is CaptureButtonUiState.Enabled.Recording.Pending)\n) {\n Log.d(TAG, "Stopping recording")\n onStopRecording()\n}\n
There was a problem hiding this comment.
I agree with @gemini-code-assist's inline comment to allow stopping the recording by releasing the long-press while in the Starting state.
However, applying the bot's fix will expose a coroutine race condition in CaptureControllerImpl.kt. Because CameraSession.kt silently ignores StopRecordingEvents if it isn't actively recording yet, a rapid start/stop sequence will freeze the camera into an endless recording loop. If the UI dispatcher executes the stop coroutine before the start coroutine sends its event, the Stop event is sent to the channel first (and ignored), and then the Start event triggers the recording infinitely.
Could we also include a quick fix in CaptureControllerImpl.kt in this PR to synchronously cancel the job?
// CaptureControllerImpl.kt
override fun stopVideoRecording() {
Log.d(TAG, "stopVideoRecording")
// Cancel job synchronously before sending Stop to prevent the race condition
recordingJob?.cancel()
recordingJob = null
scope.launch {
cameraSystem.stopVideoRecording()
}
}There was a problem hiding this comment.
Warning
You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!
temcguir
left a comment
There was a problem hiding this comment.
It would be awesome to add a quick unit test to PreviewViewModelTest.kt to ensure this specific fast-release cancellation doesn't regress.
First, add a diagnostic counter to FakeCameraSystem.kt:
var numVideoRecordingStarts = 0
override suspend fun startVideoRecording(
saveLocation: SaveLocation,
onVideoRecord: (OnVideoRecordEvent) -> Unit
) {
if (!useCasesBinded) {
throw IllegalStateException("Usecases not bound")
}
numVideoRecordingStarts++
recordingInProgress = true
}
Then we can add the following test directly inside PreviewViewModelTest.kt to verify that rapidly firing "stop" immediately cancels the "start" job:
@Test
fun fastStartAndStopVideoRecording_cancelsRecording() = runTest(StandardTestDispatcher()) {
startCameraUntilRunning()
// Start and stop immediately without advancing the dispatcher
previewViewModel.captureController.startVideoRecording()
previewViewModel.captureController.stopVideoRecording()
// Let the coroutines execute
advanceUntilIdle()
// Verify that because we cancelled the job synchronously, the start implementation
// was bypassed completely to protect us from the channel race condition!
assertThat(cameraSystem.numVideoRecordingStarts).isEqualTo(0)
}
| /** | ||
| * The video recording request has been sent and is pending initialization. | ||
| */ | ||
| data object Pending : Recording |
There was a problem hiding this comment.
Given we are mapping this directly from VideoRecordingState.Starting, could we rename Pending to Starting here (and throughout the PR)? It feels like it captures the actual state of the camera backend much more accurately than "Pending".
/**
* The video recording request has been sent and is starting.
*/
data object Starting : Recording| } | ||
|
|
||
| CaptureButtonUiState.Enabled.Recording.LockedRecording -> onStopRecording() | ||
| CaptureButtonUiState.Enabled.Recording.Pending, |
There was a problem hiding this comment.
I agree with @gemini-code-assist's inline comment to allow stopping the recording by releasing the long-press while in the Starting state.
However, applying the bot's fix will expose a coroutine race condition in CaptureControllerImpl.kt. Because CameraSession.kt silently ignores StopRecordingEvents if it isn't actively recording yet, a rapid start/stop sequence will freeze the camera into an endless recording loop. If the UI dispatcher executes the stop coroutine before the start coroutine sends its event, the Stop event is sent to the channel first (and ignored), and then the Start event triggers the recording infinitely.
Could we also include a quick fix in CaptureControllerImpl.kt in this PR to synchronously cancel the job?
// CaptureControllerImpl.kt
override fun stopVideoRecording() {
Log.d(TAG, "stopVideoRecording")
// Cancel job synchronously before sending Stop to prevent the race condition
recordingJob?.cancel()
recordingJob = null
scope.launch {
cameraSystem.stopVideoRecording()
}
}
Summary
Fixes an issue (b/535645067) where double-clicking or rapidly tapping the video recording button in quick succession crashed the app with
IllegalStateException: A recording is already in progress.Changes Made
CaptureButtonUiState.Enabled.Recording.Pending: Created a new UI state representing the initialization window when video recording is starting.VideoRecordingState.Starting: MappedVideoRecordingState.StartingtoCaptureButtonUiState.Enabled.Recording.Pendinginstead ofIdleinCaptureButtonUiStateAdapter.kt.CaptureButtonComponents.kt: AddedRecording.Pendingto the ignored/no-op states during click handling (onKeyUp), preventing rapid consecutive clicks from sending duplicateStartRecordingEvents.CameraSession.kt: Replaced throwing an unhandledIllegalStateExceptionwith logging a warning when receiving a duplicateStartRecordingEventduring active recording.CaptureButtonUiStateAdapterTest.ktto assert the newPendingstate.Bug: b/535645067