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