diff --git a/packages/image_picker/image_picker_android/CHANGELOG.md b/packages/image_picker/image_picker_android/CHANGELOG.md index 44902aa169f..90ae0f48296 100644 --- a/packages/image_picker/image_picker_android/CHANGELOG.md +++ b/packages/image_picker/image_picker_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.6+10 + +* Offloads picker result handling to separate thread. + ## 0.8.6+9 * Fixes compatibility with AGP versions older than 4.2. diff --git a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index eaee6e84ae9..6aa1181ce90 100644 --- a/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -32,6 +32,8 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * A delegate class doing the heavy lifting for the plugin. @@ -112,6 +114,7 @@ private PendingCallState( private final PermissionManager permissionManager; private final FileUriResolver fileUriResolver; private final FileUtils fileUtils; + private final ExecutorService executor; private CameraDevice cameraDevice; interface PermissionManager { @@ -134,6 +137,7 @@ interface OnPathReadyListener { private Uri pendingCameraMediaUri; private @Nullable PendingCallState pendingCallState; + private final Object pendingCallStateLock = new Object(); public ImagePickerDelegate( final Activity activity, @@ -185,7 +189,8 @@ public void onScanCompleted(String path, Uri uri) { }); } }, - new FileUtils()); + new FileUtils(), + Executors.newSingleThreadExecutor()); } /** @@ -203,7 +208,8 @@ public void onScanCompleted(String path, Uri uri) { final ImagePickerCache cache, final PermissionManager permissionManager, final FileUriResolver fileUriResolver, - final FileUtils fileUtils) { + final FileUtils fileUtils, + final ExecutorService executor) { this.activity = activity; this.externalFilesDirectory = externalFilesDirectory; this.imageResizer = imageResizer; @@ -216,6 +222,7 @@ public void onScanCompleted(String path, Uri uri) { this.fileUriResolver = fileUriResolver; this.fileUtils = fileUtils; this.cache = cache; + this.executor = executor; } void setCameraDevice(CameraDevice device) { @@ -224,19 +231,25 @@ void setCameraDevice(CameraDevice device) { // Save the state of the image picker so it can be retrieved with `retrieveLostImage`. void saveStateBeforeResult() { - if (pendingCallState == null) { - return; + ImageSelectionOptions localImageOptions; + synchronized (pendingCallStateLock) { + if (pendingCallState == null) { + return; + } + localImageOptions = pendingCallState.imageOptions; } cache.saveType( - pendingCallState.imageOptions != null + localImageOptions != null ? ImagePickerCache.CacheType.IMAGE : ImagePickerCache.CacheType.VIDEO); - if (pendingCallState.imageOptions != null) { - cache.saveDimensionWithOutputOptions(pendingCallState.imageOptions); + if (localImageOptions != null) { + cache.saveDimensionWithOutputOptions(localImageOptions); } - if (pendingCameraMediaUri != null) { - cache.savePendingCameraMediaUriPath(pendingCameraMediaUri); + + final Uri localPendingCameraMediaUri = pendingCameraMediaUri; + if (localPendingCameraMediaUri != null) { + cache.savePendingCameraMediaUriPath(localPendingCameraMediaUri); } } @@ -323,10 +336,16 @@ public void takeVideoWithCamera( private void launchTakeVideoWithCameraIntent() { Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); - if (pendingCallState != null - && pendingCallState.videoOptions != null - && pendingCallState.videoOptions.getMaxDurationSeconds() != null) { - int maxSeconds = pendingCallState.videoOptions.getMaxDurationSeconds().intValue(); + + VideoSelectionOptions localVideoOptions = null; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + localVideoOptions = pendingCallState.videoOptions; + } + } + + if (localVideoOptions != null && localVideoOptions.getMaxDurationSeconds() != null) { + int maxSeconds = localVideoOptions.getMaxDurationSeconds().intValue(); intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, maxSeconds); } if (cameraDevice == CameraDevice.FRONT) { @@ -537,27 +556,31 @@ public boolean onRequestPermissionsResult( } @Override - public boolean onActivityResult(int requestCode, int resultCode, Intent data) { + public boolean onActivityResult(final int requestCode, final int resultCode, final Intent data) { + Runnable handlerRunnable; + switch (requestCode) { case REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY: - handleChooseImageResult(resultCode, data); + handlerRunnable = () -> handleChooseImageResult(resultCode, data); break; case REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY: - handleChooseMultiImageResult(resultCode, data); + handlerRunnable = () -> handleChooseMultiImageResult(resultCode, data); break; case REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA: - handleCaptureImageResult(resultCode); + handlerRunnable = () -> handleCaptureImageResult(resultCode); break; case REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY: - handleChooseVideoResult(resultCode, data); + handlerRunnable = () -> handleChooseVideoResult(resultCode, data); break; case REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA: - handleCaptureVideoResult(resultCode); + handlerRunnable = () -> handleCaptureVideoResult(resultCode); break; default: return false; } + executor.execute(handlerRunnable); + return true; } @@ -603,9 +626,11 @@ private void handleChooseVideoResult(int resultCode, Intent data) { private void handleCaptureImageResult(int resultCode) { if (resultCode == Activity.RESULT_OK) { + final Uri localPendingCameraMediaUri = pendingCameraMediaUri; + fileUriResolver.getFullImagePath( - pendingCameraMediaUri != null - ? pendingCameraMediaUri + localPendingCameraMediaUri != null + ? localPendingCameraMediaUri : Uri.parse(cache.retrievePendingCameraMediaUriPath()), new OnPathReadyListener() { @Override @@ -622,9 +647,10 @@ public void onPathReady(String path) { private void handleCaptureVideoResult(int resultCode) { if (resultCode == Activity.RESULT_OK) { + final Uri localPendingCameraMediaUrl = pendingCameraMediaUri; fileUriResolver.getFullImagePath( - pendingCameraMediaUri != null - ? pendingCameraMediaUri + localPendingCameraMediaUrl != null + ? localPendingCameraMediaUrl : Uri.parse(cache.retrievePendingCameraMediaUriPath()), new OnPathReadyListener() { @Override @@ -641,10 +667,17 @@ public void onPathReady(String path) { private void handleMultiImageResult( ArrayList paths, boolean shouldDeleteOriginalIfScaled) { - if (pendingCallState != null && pendingCallState.imageOptions != null) { + ImageSelectionOptions localImageOptions = null; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + localImageOptions = pendingCallState.imageOptions; + } + } + + if (localImageOptions != null) { ArrayList finalPath = new ArrayList<>(); for (int i = 0; i < paths.size(); i++) { - String finalImagePath = getResizedImagePath(paths.get(i), pendingCallState.imageOptions); + String finalImagePath = getResizedImagePath(paths.get(i), localImageOptions); //delete original file if scaled if (finalImagePath != null @@ -661,8 +694,15 @@ private void handleMultiImageResult( } private void handleImageResult(String path, boolean shouldDeleteOriginalIfScaled) { - if (pendingCallState != null && pendingCallState.imageOptions != null) { - String finalImagePath = getResizedImagePath(path, pendingCallState.imageOptions); + ImageSelectionOptions localImageOptions = null; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + localImageOptions = pendingCallState.imageOptions; + } + } + + if (localImageOptions != null) { + String finalImagePath = getResizedImagePath(path, localImageOptions); //delete original file if scaled if (finalImagePath != null && !finalImagePath.equals(path) && shouldDeleteOriginalIfScaled) { new File(path).delete(); @@ -689,12 +729,13 @@ private boolean setPendingOptionsAndResult( @Nullable ImageSelectionOptions imageOptions, @Nullable VideoSelectionOptions videoOptions, @NonNull Messages.Result> result) { - if (pendingCallState != null) { - return false; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + return false; + } + pendingCallState = new PendingCallState(imageOptions, videoOptions, result); } - pendingCallState = new PendingCallState(imageOptions, videoOptions, result); - // Clean up cache if a new image picker is launched. cache.clear(); @@ -710,24 +751,39 @@ private void finishWithSuccess(@Nullable String imagePath) { if (imagePath != null) { pathList.add(imagePath); } - if (pendingCallState == null) { + + Messages.Result> localResult = null; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + localResult = pendingCallState.result; + } + pendingCallState = null; + } + + if (localResult == null) { // Only save data for later retrieval if something was actually selected. if (!pathList.isEmpty()) { cache.saveResult(pathList, null, null); } - return; + } else { + localResult.success(pathList); } - pendingCallState.result.success(pathList); - pendingCallState = null; } private void finishWithListSuccess(ArrayList imagePaths) { - if (pendingCallState == null) { + Messages.Result> localResult = null; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + localResult = pendingCallState.result; + } + pendingCallState = null; + } + + if (localResult == null) { cache.saveResult(imagePaths, null, null); - return; + } else { + localResult.success(imagePaths); } - pendingCallState.result.success(imagePaths); - pendingCallState = null; } private void finishWithAlreadyActiveError(Messages.Result> result) { @@ -735,12 +791,19 @@ private void finishWithAlreadyActiveError(Messages.Result> result) } private void finishWithError(String errorCode, String errorMessage) { - if (pendingCallState == null) { + Messages.Result> localResult = null; + synchronized (pendingCallStateLock) { + if (pendingCallState != null) { + localResult = pendingCallState.result; + } + pendingCallState = null; + } + + if (localResult == null) { cache.saveResult(null, errorCode, errorMessage); - return; + } else { + localResult.error(new FlutterError(errorCode, errorMessage, null)); } - pendingCallState.result.error(new FlutterError(errorCode, errorMessage, null)); - pendingCallState = null; } private void useFrontCamera(Intent intent) { diff --git a/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index 77a34b452b0..4648e6d4505 100644 --- a/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -7,6 +7,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; @@ -32,6 +34,7 @@ import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -64,6 +67,7 @@ public class ImagePickerDelegateTest { @Mock FileUtils mockFileUtils; @Mock Intent mockIntent; @Mock ImagePickerCache cache; + @Mock ExecutorService mockExecutor; ImagePickerDelegate.FileUriResolver mockFileUriResolver; MockedStatic mockStaticFile; @@ -349,6 +353,13 @@ public void onRequestPermissionsResult_whenCameraPermissionDenied_finishesWithEr @Test public void onActivityResult_whenPickFromGalleryCanceled_finishesWithEmptyList() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(DEFAULT_IMAGE_OPTIONS, null); @@ -364,6 +375,13 @@ public void onActivityResult_whenPickFromGalleryCanceled_finishesWithEmptyList() @Test public void onActivityResult_whenPickFromGalleryCanceled_storesNothingInCache() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegate(); delegate.onActivityResult( @@ -375,6 +393,13 @@ public void onActivityResult_whenPickFromGalleryCanceled_storesNothingInCache() @Test public void onActivityResult_whenImagePickedFromGallery_andNoResizeNeeded_finishesWithImagePath() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(DEFAULT_IMAGE_OPTIONS, null); @@ -390,6 +415,13 @@ public void onActivityResult_whenPickFromGalleryCanceled_storesNothingInCache() @Test public void onActivityResult_whenImagePickedFromGallery_andNoResizeNeeded_storesImageInCache() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegate(); delegate.onActivityResult( @@ -404,8 +436,16 @@ public void onActivityResult_whenImagePickedFromGallery_andNoResizeNeeded_stores @Test public void onActivityResult_whenImagePickedFromGallery_andResizeNeeded_finishesWithScaledImagePath() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(RESIZE_TRIGGERING_IMAGE_OPTIONS, null); + delegate.onActivityResult( ImagePickerDelegate.REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY, Activity.RESULT_OK, mockIntent); @@ -419,8 +459,16 @@ public void onActivityResult_whenImagePickedFromGallery_andNoResizeNeeded_stores @Test public void onActivityResult_whenVideoPickedFromGallery_andResizeParametersSupplied_finishesWithFilePath() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(RESIZE_TRIGGERING_IMAGE_OPTIONS, null); + delegate.onActivityResult( ImagePickerDelegate.REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY, Activity.RESULT_OK, mockIntent); @@ -433,6 +481,13 @@ public void onActivityResult_whenImagePickedFromGallery_andNoResizeNeeded_stores @Test public void onActivityResult_whenTakeImageWithCameraCanceled_finishesWithEmptyList() { + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(DEFAULT_IMAGE_OPTIONS, null); @@ -448,9 +503,16 @@ public void onActivityResult_whenTakeImageWithCameraCanceled_finishesWithEmptyLi @Test public void onActivityResult_whenImageTakenWithCamera_andNoResizeNeeded_finishesWithImagePath() { + when(cache.retrievePendingCameraMediaUriPath()).thenReturn("testString"); + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(DEFAULT_IMAGE_OPTIONS, null); - when(cache.retrievePendingCameraMediaUriPath()).thenReturn("testString"); delegate.onActivityResult( ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA, Activity.RESULT_OK, mockIntent); @@ -466,9 +528,16 @@ public void onActivityResult_whenImageTakenWithCamera_andNoResizeNeeded_finishes public void onActivityResult_whenImageTakenWithCamera_andResizeNeeded_finishesWithScaledImagePath() { when(cache.retrievePendingCameraMediaUriPath()).thenReturn("testString"); - + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(RESIZE_TRIGGERING_IMAGE_OPTIONS, null); + delegate.onActivityResult( ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA, Activity.RESULT_OK, mockIntent); @@ -483,9 +552,16 @@ public void onActivityResult_whenImageTakenWithCamera_andNoResizeNeeded_finishes public void onActivityResult_whenVideoTakenWithCamera_andResizeParametersSupplied_finishesWithFilePath() { when(cache.retrievePendingCameraMediaUriPath()).thenReturn("testString"); - + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions(RESIZE_TRIGGERING_IMAGE_OPTIONS, null); + delegate.onActivityResult( ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA, Activity.RESULT_OK, mockIntent); @@ -500,10 +576,17 @@ public void onActivityResult_whenImageTakenWithCamera_andNoResizeNeeded_finishes public void onActivityResult_whenVideoTakenWithCamera_andMaxDurationParametersSupplied_finishesWithFilePath() { when(cache.retrievePendingCameraMediaUriPath()).thenReturn("testString"); - + Mockito.doAnswer( + invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }) + .when(mockExecutor) + .execute(any(Runnable.class)); ImagePickerDelegate delegate = createDelegateWithPendingResultAndOptions( null, new VideoSelectionOptions.Builder().setMaxDurationSeconds(MAX_DURATION).build()); + delegate.onActivityResult( ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA, Activity.RESULT_OK, mockIntent); @@ -514,6 +597,80 @@ public void onActivityResult_whenImageTakenWithCamera_andNoResizeNeeded_finishes verifyNoMoreInteractions(mockResult); } + @Test + public void onActivityResult_whenImagePickedFromGallery_returnsTrue() { + ImagePickerDelegate delegate = createDelegate(); + + boolean isHandled = + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY, + Activity.RESULT_OK, + mockIntent); + + assertTrue(isHandled); + } + + @Test + public void onActivityResult_whenMultipleImagesPickedFromGallery_returnsTrue() { + ImagePickerDelegate delegate = createDelegate(); + + boolean isHandled = + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY, + Activity.RESULT_OK, + mockIntent); + + assertTrue(isHandled); + } + + @Test + public void onActivityResult_whenVideoPickerFromGallery_returnsTrue() { + ImagePickerDelegate delegate = createDelegate(); + + boolean isHandled = + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY, + Activity.RESULT_OK, + mockIntent); + + assertTrue(isHandled); + } + + @Test + public void onActivityResult_whenImageTakenWithCamera_returnsTrue() { + ImagePickerDelegate delegate = createDelegate(); + + boolean isHandled = + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA, + Activity.RESULT_OK, + mockIntent); + + assertTrue(isHandled); + } + + @Test + public void onActivityResult_whenVideoTakenWithCamera_returnsTrue() { + ImagePickerDelegate delegate = createDelegate(); + + boolean isHandled = + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA, + Activity.RESULT_OK, + mockIntent); + + assertTrue(isHandled); + } + + @Test + public void onActivityResult_withUnknownRequest_returnsFalse() { + ImagePickerDelegate delegate = createDelegate(); + + boolean isHandled = delegate.onActivityResult(314, Activity.RESULT_OK, mockIntent); + + assertFalse(isHandled); + } + private ImagePickerDelegate createDelegate() { return new ImagePickerDelegate( mockActivity, @@ -525,7 +682,8 @@ private ImagePickerDelegate createDelegate() { cache, mockPermissionManager, mockFileUriResolver, - mockFileUtils); + mockFileUtils, + mockExecutor); } private ImagePickerDelegate createDelegateWithPendingResultAndOptions( @@ -540,7 +698,8 @@ private ImagePickerDelegate createDelegateWithPendingResultAndOptions( cache, mockPermissionManager, mockFileUriResolver, - mockFileUtils); + mockFileUtils, + mockExecutor); } private void verifyFinishedWithAlreadyActiveError() { diff --git a/packages/image_picker/image_picker_android/pubspec.yaml b/packages/image_picker/image_picker_android/pubspec.yaml index e95d14c84c9..ec6a0b3b767 100755 --- a/packages/image_picker/image_picker_android/pubspec.yaml +++ b/packages/image_picker/image_picker_android/pubspec.yaml @@ -3,7 +3,7 @@ description: Android implementation of the image_picker plugin. repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.6+9 +version: 0.8.6+10 environment: sdk: ">=2.18.0 <4.0.0"