From a6c8a09b12560bb760ae6fc3db6d06cb5a7a9ee6 Mon Sep 17 00:00:00 2001 From: kwikwag <392199+kwikwag@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:06:00 +0300 Subject: [PATCH] [camera_android] Guard capture-session operations against teardown races ## Summary Prevents crashes caused by `CameraCaptureSession` being cleared, replaced, or closed while an asynchronous camera operation is still in progress. This addresses: * `NullPointerException` in `unlockAutoFocus` when the session is cleared between being checked and used. * `IllegalStateException` when an operation is submitted to a session whose underlying `CameraDevice` has already closed. * Stale capture callbacks attempting to operate on a newer capture session. Fixes flutter/flutter#82031 Fixes flutter/flutter#158878 ## Changes * Snapshot the active capture session and consistently use that snapshot throughout the camera operation, including passing it into helper methods. * Mark `captureSession` as `volatile` to ensure that updates are visible across the background and platform threads. * Ignore still-capture completion callbacks from sessions that are no longer current. * Handle `IllegalStateException` from stale or closed sessions. * Report an appropriate camera error when a still-capture request cannot continue * Add regression tests covering null, closed, replaced, and mid-operation session changes. * Mark `captureFile` as `@VisibleForTesting` for the new video-recording teardown tests. * Remove `throws` from `Camera.pausePreview` and handle exceptions internally. Some things I didn't change which might be wanted: * The AI assistant really wanted to invoke `cameraCaptureCallback.setCameraState(CameraState.STATE_PREVIEW)` on failure conditions so as not to reach bad state. However, existing exception handling didn't reset state, so this is out of scope for what this pull request is trying to fix. * The responsibility for exception handling is divided inconsistently between `Camera` and `CameraApiImpl`. Specifically this was apparent when applying the fix to `pausePreview()`, as it would be the only function where I wouldn't deal with the exception internally in `Camera`. I decided to match `pausePreview()` with `resumePreview()` in the sense that it handles exceptions internally. Generally, I think exception handling should be made consistent, either in `Camera` or in `CameraApiImpl`. In addition, FlutterError error names should be made more consistent - e.g. `CameraAccessException` sometimes bubbles up under the same name and sometime as `CameraAccess`. * Some places such as `runPrecaptureSequence` silently swallow exceptions rather than raising it to Flutter. I followed their lead, but it might be wrong. ## Testing Added Android unit tests covering: * Session removal between related capture calls. * Session replacement while an operation is in progress. * Completion callbacks arriving from stale sessions. * Closed sessions throwing `IllegalStateException`. * Null-session handling during still capture and recording. --- packages/camera/camera_android/CHANGELOG.md | 4 + .../io/flutter/plugins/camera/Camera.java | 161 +++++++++-- .../flutter/plugins/camera/CameraApiImpl.java | 6 +- .../plugins/camera/CameraApiImplTest.java | 10 - .../io/flutter/plugins/camera/CameraTest.java | 270 ++++++++++++++++++ packages/camera/camera_android/pubspec.yaml | 2 +- 6 files changed, 407 insertions(+), 46 deletions(-) diff --git a/packages/camera/camera_android/CHANGELOG.md b/packages/camera/camera_android/CHANGELOG.md index 7ec60946aab0..0b2db6f17234 100644 --- a/packages/camera/camera_android/CHANGELOG.md +++ b/packages/camera/camera_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.11+1 + +* Fixes crashes (`NullPointerException`, `CameraDevice was already closed`) due to a race condition. + ## 0.10.11 * Adds `setJpegImageQuality` for controlling JPEG compression quality. diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java index 95149befabdd..c1e30ad58a77 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -120,7 +120,13 @@ class Camera private HandlerThread backgroundHandlerThread; CameraDeviceWrapper cameraDevice; - CameraCaptureSession captureSession; + + /** + * Written from the background handler thread (via {@link CameraCaptureSession.StateCallback}) and + * read from both that thread and the platform thread. + */ + volatile CameraCaptureSession captureSession; + @VisibleForTesting ImageReader pictureImageReader; ImageStreamReader imageStreamReader; @@ -135,7 +141,7 @@ class Camera /** True when the preview is paused. */ @VisibleForTesting boolean pausedPreview; - private File captureFile; + @VisibleForTesting File captureFile; /** Holds the current capture timeouts */ private CaptureTimeoutsWrapper captureTimeouts; @@ -520,7 +526,9 @@ public void onConfigured(@NonNull CameraCaptureSession session) { updateBuilderSettings(previewRequestBuilder); refreshPreviewCaptureSession( - onSuccessCallback, (code, message) -> dartMessenger.sendCameraErrorEvent(message)); + session, + onSuccessCallback, + (code, message) -> dartMessenger.sendCameraErrorEvent(message)); } @Override @@ -574,12 +582,13 @@ private void createCaptureSession( cameraDevice.createCaptureSession(surfaces, callback, backgroundHandler); } - // Send a repeating request to refresh capture session. + // Send a repeating request to refresh capture session. void refreshPreviewCaptureSession( @Nullable Runnable onSuccessCallback, @NonNull ErrorCallback onErrorCallback) { - Log.i(TAG, "refreshPreviewCaptureSession"); + // captureSession might be modified in another thread during this method's run + CameraCaptureSession session = captureSession; - if (captureSession == null) { + if (session == null) { Log.i( TAG, "refreshPreviewCaptureSession: captureSession not yet initialized, " @@ -587,9 +596,19 @@ void refreshPreviewCaptureSession( return; } + refreshPreviewCaptureSession(session, onSuccessCallback, onErrorCallback); + } + + // Send a repeating request to refresh capture session, using a specific local copy of a session. + void refreshPreviewCaptureSession( + @NonNull CameraCaptureSession session, + @Nullable Runnable onSuccessCallback, + @NonNull ErrorCallback onErrorCallback) { + Log.i(TAG, "refreshPreviewCaptureSession"); + try { if (!pausedPreview) { - captureSession.setRepeatingRequest( + session.setRepeatingRequest( previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); } @@ -667,11 +686,18 @@ private void runPrecaptureSequence() { previewRequestBuilder.set( CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE); - captureSession.capture( - previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); + + // captureSession might be modified in another thread during this method's run + CameraCaptureSession session = captureSession; + if (session == null) { + Log.i(TAG, "[runPrecaptureSequence] captureSession null, returning"); + return; + } + session.capture(previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); // Repeating request to refresh preview session. refreshPreviewCaptureSession( + session, null, (code, message) -> dartMessenger.error(flutterResult, "cameraAccess", message, null)); @@ -683,7 +709,7 @@ private void runPrecaptureSequence() { CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START); // Trigger one capture to start AE sequence. - captureSession.capture( + session.capture( previewRequestBuilder.build(), createTriggerResetCallback( CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, @@ -692,6 +718,8 @@ private void runPrecaptureSequence() { } catch (CameraAccessException e) { e.printStackTrace(); + } catch (IllegalStateException e) { + e.printStackTrace(); } } @@ -762,15 +790,35 @@ public void onCaptureCompleted( @NonNull CameraCaptureSession session, @NonNull CaptureRequest request, @NonNull TotalCaptureResult result) { - unlockAutoFocus(); + // Ignore completions from a session that has since been replaced or torn down; + // acting on it here would run autofocus operations against the wrong session. + if (session != captureSession) { + Log.i(TAG, "Ignoring completion callback from a stale capture session."); + return; + } + unlockAutoFocus(session); } }; + // captureSession might be modified in another thread during this method's run + CameraCaptureSession session = captureSession; + if (session == null) { + Log.i(TAG, "[takePictureAfterPrecapture] captureSession null, returning"); + dartMessenger.error( + flutterResult, + "cameraAccess", + "The camera capture session is no longer available.", + null); + return; + } try { Log.i(TAG, "sending capture request"); - captureSession.capture(stillBuilder.build(), captureCallback, backgroundHandler); + session.capture(stillBuilder.build(), captureCallback, backgroundHandler); } catch (CameraAccessException e) { dartMessenger.error(flutterResult, "cameraAccess", e.getMessage(), null); + } catch (IllegalStateException e) { + dartMessenger.error( + flutterResult, "cameraAccess", "Camera is closed: " + e.getMessage(), null); } } @@ -813,17 +861,24 @@ private void runPictureAutoFocus() { private void lockAutoFocus() { Log.i(TAG, "lockAutoFocus"); - if (captureSession == null) { - Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); + + // captureSession might be modified in another thread during this method's run + CameraCaptureSession session = captureSession; + if (session == null) { + Log.i(TAG, "[lockAutoFocus] captureSession null, returning"); return; } + lockAutoFocus(session); + } + /** Locks autofocus, using a specific local copy of a session. */ + private void lockAutoFocus(@NonNull CameraCaptureSession session) { // Trigger AF to start. previewRequestBuilder.set( CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START); try { - captureSession.capture( + session.capture( previewRequestBuilder.build(), createTriggerResetCallback( CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE), @@ -834,27 +889,47 @@ private void lockAutoFocus() { ? "CameraAccessException occurred while locking autofocus." : e.getMessage(); dartMessenger.sendCameraErrorEvent(message); + } catch (IllegalStateException e) { + if (BuildConfig.DEBUG) { + Log.i(TAG, "[lockAutoFocus] capture() failed, capture session no longer valid: " + e); + } + String message = + (e.getMessage() == null) + ? "IllegalStateException occurred while locking autofocus." + : e.getMessage(); + dartMessenger.sendCameraErrorEvent(message); } } /** Cancel and reset auto focus state and refresh the preview session. */ void unlockAutoFocus() { Log.i(TAG, "unlockAutoFocus"); - if (captureSession == null) { + + // captureSession might be modified in another thread during this method's run + CameraCaptureSession session = captureSession; + if (session == null) { Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); return; } + unlockAutoFocus(session); + } + + /** + * Cancels and resets auto focus state and refreshes the preview session, using a specific local + * copy of a session. + */ + void unlockAutoFocus(@NonNull CameraCaptureSession session) { try { // Cancel existing AF state. previewRequestBuilder.set( CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL); - captureSession.capture(previewRequestBuilder.build(), null, backgroundHandler); + session.capture(previewRequestBuilder.build(), null, backgroundHandler); // Set AF state to idle again. previewRequestBuilder.set( CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); - captureSession.capture(previewRequestBuilder.build(), null, backgroundHandler); + session.capture(previewRequestBuilder.build(), null, backgroundHandler); } catch (CameraAccessException e) { String message = (e.getMessage() == null) @@ -862,9 +937,20 @@ void unlockAutoFocus() { : e.getMessage(); dartMessenger.sendCameraErrorEvent(message); return; + } catch (IllegalStateException e) { + if (BuildConfig.DEBUG) { + Log.i(TAG, "[unlockAutoFocus] capture() failed, capture session no longer valid: " + e); + } + String message = + (e.getMessage() == null) + ? "IllegalStateException occurred while unlocking autofocus." + : e.getMessage(); + dartMessenger.sendCameraErrorEvent(message); + return; } refreshPreviewCaptureSession( + session, null, (errorCode, errorMessage) -> dartMessenger.error(flutterResult, errorCode, errorMessage, null)); @@ -907,7 +993,10 @@ public String stopVideoRecording() { recordingVideo = false; try { closeRenderer(); - captureSession.abortCaptures(); + CameraCaptureSession session = captureSession; + if (session != null) { + session.abortCaptures(); + } mediaRecorder.stop(); } catch (CameraAccessException | IllegalStateException e) { // Ignore exceptions and try to continue (changes are camera session already aborted capture). @@ -1040,22 +1129,25 @@ public void setFocusMode(@NonNull FocusMode newMode) { switch (newMode) { case locked: // Perform a single focus trigger. - if (captureSession == null) { - Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); + CameraCaptureSession session = captureSession; + if (session == null) { + Log.i(TAG, "[setFocusMode] captureSession null, returning"); return; } - lockAutoFocus(); + lockAutoFocus(session); // Set AF state to idle again. previewRequestBuilder.set( CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); try { - captureSession.setRepeatingRequest( - previewRequestBuilder.build(), null, backgroundHandler); + session.setRepeatingRequest(previewRequestBuilder.build(), null, backgroundHandler); } catch (CameraAccessException e) { throw new Messages.FlutterError( "setFocusModeFailed", "Error setting focus mode: " + e.getMessage(), null); + } catch (IllegalStateException e) { + throw new Messages.FlutterError( + "setFocusModeFailed", "Camera is closed: " + e.getMessage(), null); } break; case auto: @@ -1177,12 +1269,20 @@ public void unlockCaptureOrientation() { } /** Pause the preview from dart. */ - public void pausePreview() throws CameraAccessException { + public void pausePreview() { if (!this.pausedPreview) { this.pausedPreview = true; - if (this.captureSession != null) { - this.captureSession.stopRepeating(); + CameraCaptureSession session = captureSession; + if (session != null) { + try { + session.stopRepeating(); + } catch (CameraAccessException e) { + throw new Messages.FlutterError("CameraAccessException", e.getMessage(), null); + } catch (IllegalStateException e) { + throw new Messages.FlutterError( + "CameraAccessException", "Camera is closed: " + e.getMessage(), null); + } } } } @@ -1346,11 +1446,12 @@ void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStre } void closeCaptureSession() { - if (captureSession != null) { + CameraCaptureSession session = captureSession; + captureSession = null; + if (session != null) { Log.i(TAG, "closeCaptureSession"); - captureSession.close(); - captureSession = null; + session.close(); } } diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java index 28a8d9a17a27..7ee4067759a3 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java @@ -321,11 +321,7 @@ public void unlockCaptureOrientation() { @Override public void pausePreview() { - try { - camera.pausePreview(); - } catch (CameraAccessException e) { - throw new Messages.FlutterError("CameraAccessException", e.getMessage(), null); - } + camera.pausePreview(); } @Override diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraApiImplTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraApiImplTest.java index 9db4413a2c84..a29159e488b2 100644 --- a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraApiImplTest.java +++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraApiImplTest.java @@ -5,8 +5,6 @@ package io.flutter.plugins.camera; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -54,14 +52,6 @@ public void onMethodCall_pausePreview_shouldPausePreviewAndSendSuccessResult() verify(mockCamera, times(1)).pausePreview(); } - @Test - public void onMethodCall_pausePreview_shouldSendErrorResultOnCameraAccessException() - throws CameraAccessException { - doThrow(new CameraAccessException(0)).when(mockCamera).pausePreview(); - - assertThrows(Messages.FlutterError.class, () -> handler.pausePreview()); - } - @Test public void onMethodCall_resumePreview_shouldResumePreviewAndSendSuccessResult() { handler.resumePreview(); diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java index 4fd4f3a65954..a578faf27c1c 100644 --- a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java +++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -65,6 +65,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.InOrder; import org.mockito.MockedConstruction; @@ -712,6 +713,51 @@ public void resumeVideoRecording_shouldCallPauseWhenRecording() { verify(mockMediaRecorder, times(1)).resume(); } + @Test + public void stopVideoRecording_shouldAbortCaptureSession() + throws IOException, CameraAccessException { + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + camera.mediaRecorder = mockMediaRecorder; + camera.recordingVideo = true; + camera.captureFile = File.createTempFile("REC", ".mp4"); + + camera.stopVideoRecording(); + + verify(mockCaptureSession, times(1)).abortCaptures(); + verify(mockMediaRecorder, times(1)).stop(); + } + + @Test + public void stopVideoRecording_shouldNotThrowWhenNullCaptureSession() + throws IOException, CameraAccessException { + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + camera.mediaRecorder = mockMediaRecorder; + camera.recordingVideo = true; + camera.captureFile = File.createTempFile("REC", ".mp4"); + camera.captureSession = null; + + camera.stopVideoRecording(); + + verify(mockCaptureSession, never()).abortCaptures(); + verify(mockMediaRecorder, times(1)).stop(); + } + + @Test + public void stopVideoRecording_shouldNotThrowWhenCaptureSessionIsStale() + throws IOException, CameraAccessException { + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + camera.mediaRecorder = mockMediaRecorder; + camera.recordingVideo = true; + camera.captureFile = File.createTempFile("REC", ".mp4"); + doThrow(new IllegalStateException("CameraCaptureSession was already closed")) + .when(mockCaptureSession) + .abortCaptures(); + + camera.stopVideoRecording(); + + verify(mockCaptureSession, times(1)).abortCaptures(); + } + @Test public void setDescriptionWhileRecording_errorsWhenUnsupported() { MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); @@ -919,6 +965,39 @@ public void setFocusMode_shouldSendErrorEventOnUnlockAutoFocusCameraAccessExcept verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); } + @Test + public void setFocusMode_shouldUseSessionSnapshotCapturedAtEntryForUnlockAutoFocus() + throws CameraAccessException { + // unlockAutoFocus() reads captureSession into a local variable once and reuses it for both + // capture() calls. We inject a field mutation during the first call to simulate another + // thread setting it to null, and this must not affect the second. + when(mockCaptureSession.capture(any(), any(), any())) + .thenAnswer( + (Answer) + invocation -> { + camera.captureSession = null; + return 0; + }); + + camera.setFocusMode(FocusMode.auto); + + verify(mockCaptureSession, times(2)).capture(any(), any(), any()); + } + + @Test + public void setFocusMode_shouldNotThrowWhenUnlockAutoFocusCaptureSessionIsStale() + throws CameraAccessException { + // captureSession is non-null but the underlying CameraDevice was torn down concurrently, so + // capture() throws IllegalStateException rather than CameraAccessException. + when(mockCaptureSession.capture(any(), any(), any())) + .thenThrow(new IllegalStateException("CameraDevice was already closed")); + + camera.setFocusMode(FocusMode.auto); + + verify(mockCaptureSession, times(1)).capture(any(), any(), any()); + verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); + } + @Test public void startVideoRecording_shouldPullStreamsFromMediaRecorderAndImageReader() throws InterruptedException, IOException, CameraAccessException { @@ -977,6 +1056,42 @@ public void setFocusMode_shouldSendErrorEventOnLockAutoFocusCameraAccessExceptio verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); } + @Test + public void setFocusMode_shouldNotThrowWhenLockAutoFocusCaptureSessionIsStale() + throws CameraAccessException { + when(mockCaptureSession.capture(any(), any(), any())) + .thenThrow(new IllegalStateException("CameraDevice was already closed")); + + camera.setFocusMode(FocusMode.locked); + + verify(mockCaptureSession, times(1)).capture(any(), any(), any()); + verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); + } + + @Test + public void setFocusMode_shouldUseSessionSnapshotCapturedAtEntryForLockAutoFocus() + throws CameraAccessException { + // setFocusMode() reuses the session snapshot captured before lockAutoFocus() runs, so a + // field mutation in between (session replaced) must not redirect setRepeatingRequest() to + // the new session. + CameraCaptureSession newerCaptureSession = mock(CameraCaptureSession.class); + when(mockCaptureSession.capture(any(), any(), any())) + .thenAnswer( + (Answer) + invocation -> { + camera.captureSession = newerCaptureSession; + return 0; + }); + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new IllegalStateException("CameraCaptureSession was already closed")); + + assertThrows(Messages.FlutterError.class, () -> camera.setFocusMode(FocusMode.locked)); + + verify(mockCaptureSession, times(1)).capture(any(), any(), any()); + verify(mockCaptureSession, times(1)).setRepeatingRequest(any(), any(), any()); + verify(newerCaptureSession, never()).setRepeatingRequest(any(), any(), any()); + } + @Test public void createTriggerResetCallback_shouldResetTriggerOnCaptureCompleted() { CaptureRequest.Key triggerKey = CaptureRequest.CONTROL_AF_TRIGGER; @@ -1125,6 +1240,24 @@ public void pausePreview_shouldPausePreview() throws CameraAccessException { verify(mockCaptureSession, times(1)).stopRepeating(); } + @Test + public void pausePreview_shouldThrowFlutterErrorOnIllegalStateException() + throws CameraAccessException { + doThrow(new IllegalStateException("CameraDevice was already closed")) + .when(mockCaptureSession) + .stopRepeating(); + + assertThrows(Messages.FlutterError.class, camera::pausePreview); + } + + @Test + public void pausePreview_shouldThrowFlutterErrorOnCameraAccessException() + throws CameraAccessException { + doThrow(new CameraAccessException(0)).when(mockCaptureSession).stopRepeating(); + + assertThrows(Messages.FlutterError.class, camera::pausePreview); + } + @Test public void resumePreview_shouldResumePreview() throws CameraAccessException { camera.resumePreview(); @@ -1144,6 +1277,66 @@ public void resumePreview_shouldSendErrorEventOnCameraAccessException() verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); } + @Test + public void resumePreview_shouldSendErrorEventWhenCaptureSessionIsStale() + throws CameraAccessException { + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new IllegalStateException("CameraDevice was already closed")); + + camera.resumePreview(); + + verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); + } + + @Test + public void runPrecaptureSequence_shouldStartAePrecaptureSequence() throws CameraAccessException { + camera.onPrecapture(); + + verify(mockCaptureSession, times(2)).capture(any(), any(), any()); + verify(mockCaptureSession, times(1)).setRepeatingRequest(any(), any(), any()); + } + + @Test + public void runPrecaptureSequence_shouldSkipCaptureWhenNullCaptureSession() + throws CameraAccessException { + camera.captureSession = null; + + camera.onPrecapture(); + + verify(mockCaptureSession, never()).capture(any(), any(), any()); + } + + @Test + public void runPrecaptureSequence_shouldUseSessionSnapshotCapturedAtEntry() + throws CameraAccessException { + // runPrecaptureSequence() reads captureSession into a local variable once and reuses it for + // both the capture() call and the subsequent refreshPreviewCaptureSession() call. We inject a + // field mutation during the capture() call to simulate another thread clearing it, and this + // must not affect the refresh. + when(mockCaptureSession.capture(any(), any(), any())) + .thenAnswer( + (Answer) + invocation -> { + camera.captureSession = null; + return 0; + }); + + camera.onPrecapture(); + + verify(mockCaptureSession, times(1)).setRepeatingRequest(any(), any(), any()); + } + + @Test + public void runPrecaptureSequence_shouldNotThrowWhenCaptureSessionIsStale() + throws CameraAccessException { + when(mockCaptureSession.capture(any(), any(), any())) + .thenThrow(new IllegalStateException("CameraDevice was already closed")); + + camera.onPrecapture(); + + verify(mockCaptureSession, times(1)).capture(any(), any(), any()); + } + @Test public void startBackgroundThread_shouldStartNewThread() { camera.startBackgroundThread(); @@ -1192,6 +1385,83 @@ public void onConverge_shouldTakePictureWithoutAbortingSession() throws CameraAc verify(mockCaptureSession, never()).abortCaptures(); } + @Test + public void onConverge_shouldReportErrorWhenCaptureSessionIsStale() throws CameraAccessException { + @SuppressWarnings("unchecked") + Messages.Result mockResult = mock(Messages.Result.class); + ArrayList mockRequestBuilders = new ArrayList<>(); + mockRequestBuilders.add(mock(CaptureRequest.Builder.class)); + CameraDeviceWrapper fakeCamera = new FakeCameraDeviceWrapper(mockRequestBuilders); + camera.cameraDevice = fakeCamera; + camera.flutterResult = mockResult; + camera.pictureImageReader = mock(ImageReader.class); + SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature(mockCameraProperties, null, null); + DeviceOrientationManager mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + when(mockCaptureSession.capture(any(), any(), any())) + .thenThrow(new IllegalStateException("CameraDevice was already closed")); + + camera.onConverged(); + + verify(mockDartMessenger, times(1)).error(eq(mockResult), eq("cameraAccess"), any(), eq(null)); + } + + @Test + public void onConverge_shouldSkipCaptureWhenNullCaptureSession() throws CameraAccessException { + ArrayList mockRequestBuilders = new ArrayList<>(); + mockRequestBuilders.add(mock(CaptureRequest.Builder.class)); + CameraDeviceWrapper fakeCamera = new FakeCameraDeviceWrapper(mockRequestBuilders); + camera.cameraDevice = fakeCamera; + camera.pictureImageReader = mock(ImageReader.class); + SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature(mockCameraProperties, null, null); + DeviceOrientationManager mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + camera.captureSession = null; + + camera.onConverged(); + + verify(mockCaptureSession, never()).capture(any(), any(), any()); + } + + @Test + public void onConverge_stillCaptureCallback_ignoresStaleSessionCompletion() + throws CameraAccessException { + // Reproduces the crash from flutter/flutter#82031: a still-capture completion callback + // arrives after captureSession has already been replaced by a newer session. The stale + // callback must not run autofocus operations against the newer session. + ArrayList mockRequestBuilders = new ArrayList<>(); + mockRequestBuilders.add(mock(CaptureRequest.Builder.class)); + CameraDeviceWrapper fakeCamera = new FakeCameraDeviceWrapper(mockRequestBuilders); + camera.cameraDevice = fakeCamera; + camera.pictureImageReader = mock(ImageReader.class); + SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature(mockCameraProperties, null, null); + DeviceOrientationManager mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + + ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(CameraCaptureSession.CaptureCallback.class); + + camera.onConverged(); + + verify(mockCaptureSession).capture(any(), callbackCaptor.capture(), any()); + CameraCaptureSession.CaptureCallback stillCaptureCallback = callbackCaptor.getValue(); + + // Simulate the capture session being replaced before the still-capture callback fires. + CameraCaptureSession newerCaptureSession = mock(CameraCaptureSession.class); + camera.captureSession = newerCaptureSession; + + stillCaptureCallback.onCaptureCompleted( + mockCaptureSession, mock(CaptureRequest.class), mock(TotalCaptureResult.class)); + + verify(newerCaptureSession, never()).capture(any(), any(), any()); + } + @Test public void createCaptureSession_doesNotCloseCaptureSession() throws CameraAccessException { Surface mockSurface = mock(Surface.class); diff --git a/packages/camera/camera_android/pubspec.yaml b/packages/camera/camera_android/pubspec.yaml index bda26510ce31..18d9b508ba7f 100644 --- a/packages/camera/camera_android/pubspec.yaml +++ b/packages/camera/camera_android/pubspec.yaml @@ -3,7 +3,7 @@ description: Android implementation of the camera plugin. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.10.11 +version: 0.10.11+1 environment: sdk: ^3.10.0