diff --git a/packages/image_picker/image_picker_ios/CHANGELOG.md b/packages/image_picker/image_picker_ios/CHANGELOG.md index a8b8c31a14f..d39c4ec88ea 100644 --- a/packages/image_picker/image_picker_ios/CHANGELOG.md +++ b/packages/image_picker/image_picker_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.14 + +* Fixes Future not completing when image picker is dismissed quickly before fully appearing. + ## 0.8.13+3 * Fixes a performance regression on iOS where picking videos could cause a long delay due to transcoding. The picker is now configured to request the original asset to avoid conversion. diff --git a/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m b/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m index 85c88d46c15..260a03fe7da 100644 --- a/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m +++ b/packages/image_picker/image_picker_ios/example/ios/RunnerTests/ImagePickerPluginTests.m @@ -725,4 +725,218 @@ - (void)testPickVideoSetsCurrentRepresentationMode API_AVAILABLE(ios(14)) { OCMVerifyAll(mockPickerViewController); } +#pragma mark - Test immediate picker close detection + +- (void)testUIImagePickerImmediateCloseReturnsEmptyArray { + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] init]; + + UIImagePickerController *controller = [[UIImagePickerController alloc] init]; + [plugin setImagePickerControllerOverrides:@[ controller ]]; + + // Mock camera access to avoid permission dialogs and device-specific logic. + id mockUIImagePicker = OCMClassMock([UIImagePickerController class]); + OCMStub(ClassMethod( + [mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + .andReturn(YES); + OCMStub(ClassMethod( + [mockUIImagePicker isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceRear])) + .andReturn(YES); + id mockAVCaptureDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub([mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + .andReturn(AVAuthorizationStatusAuthorized); + + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; + + FLTSourceSpecification *source = [FLTSourceSpecification makeWithType:FLTSourceTypeCamera + camera:FLTSourceCameraRear]; + [plugin pickImageWithSource:source + maxSize:[[FLTMaxSize alloc] init] + quality:nil + fullMetadata:NO + completion:^(NSString *_Nullable result, FlutterError *_Nullable error) { + XCTAssertNil(result); + XCTAssertNil(error); + [resultExpectation fulfill]; + }]; + + // The `pickImage` call will attach the observer. Now, simulate dismissal. + // This needs to happen on the next run loop to ensure the observer is attached. + dispatch_async(dispatch_get_main_queue(), ^{ + UIWindow *testWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + testWindow.hidden = NO; + [testWindow addSubview:controller.view]; + + [testWindow setNeedsLayout]; + [testWindow layoutIfNeeded]; + + // Simulate the picker being removed from the window hierarchy + [controller.view removeFromSuperview]; + }); + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testPHPickerImmediateCloseReturnsEmptyArray API_AVAILABLE(ios(14)) { + id photoLibrary = OCMClassMock([PHPhotoLibrary class]); + OCMStub(ClassMethod([photoLibrary authorizationStatus])) + .andReturn(PHAuthorizationStatusAuthorized); + + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] init]; + + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; + + [plugin pickMultiImageWithMaxSize:[[FLTMaxSize alloc] init] + quality:nil + fullMetadata:NO + limit:nil + completion:^(NSArray *_Nullable result, + FlutterError *_Nullable error) { + XCTAssertNotNil(result); + XCTAssertEqual(result.count, 0); + XCTAssertNil(error); + [resultExpectation fulfill]; + }]; + + id mockPresentationController = OCMClassMock([UIPresentationController class]); + [plugin presentationControllerDidDismiss:mockPresentationController]; + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testObserverDoesNotInterfereWhenProcessingSelection API_AVAILABLE(ios(14)) { + id photoLibrary = OCMClassMock([PHPhotoLibrary class]); + OCMStub(ClassMethod([photoLibrary authorizationStatus])) + .andReturn(PHAuthorizationStatusAuthorized); + + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] init]; + + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; + __block BOOL emptyResultReceived = NO; + + [plugin pickMultiImageWithMaxSize:[[FLTMaxSize alloc] init] + quality:nil + fullMetadata:NO + limit:nil + completion:^(NSArray *_Nullable result, + FlutterError *_Nullable error) { + if (result != nil && result.count > 0) { + emptyResultReceived = NO; + [resultExpectation fulfill]; + } else if (result != nil && result.count == 0) { + emptyResultReceived = YES; + } + }]; + + NSURL *tiffURL = [[NSBundle bundleForClass:[self class]] URLForResource:@"tiffImage" + withExtension:@"tiff"]; + NSItemProvider *tiffItemProvider = [[NSItemProvider alloc] initWithContentsOfURL:tiffURL]; + PHPickerResult *tiffResult = OCMClassMock([PHPickerResult class]); + OCMStub([tiffResult itemProvider]).andReturn(tiffItemProvider); + + id mockPickerViewController = OCMClassMock([PHPickerViewController class]); + + [plugin picker:mockPickerViewController didFinishPicking:@[ tiffResult ]]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if (!resultExpectation.inverted) { + XCTAssertFalse(emptyResultReceived, + @"Observer should not fire when processing selection"); + } + }); + + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testObserverRespectsContextClearing { + id photoLibrary = OCMClassMock([PHPhotoLibrary class]); + OCMStub(ClassMethod([photoLibrary authorizationStatus])) + .andReturn(PHAuthorizationStatusAuthorized); + + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] init]; + UIImagePickerController *controller = [[UIImagePickerController alloc] init]; + [plugin setImagePickerControllerOverrides:@[ controller ]]; + + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; + __block NSInteger completionCallCount = 0; + + [plugin pickImageWithSource:[FLTSourceSpecification makeWithType:FLTSourceTypeGallery + camera:FLTSourceCameraRear] + maxSize:[[FLTMaxSize alloc] init] + quality:nil + fullMetadata:NO + completion:^(NSString *_Nullable result, FlutterError *_Nullable error) { + completionCallCount++; + [resultExpectation fulfill]; + }]; + + XCTAssertNotNil(plugin.callContext, @"Context should be set after pickImage call"); + + plugin.callContext = nil; + + UIView *controllerView = controller.view; + if (controllerView) { + UIWindow *testWindow = [[UIWindow alloc] init]; + [testWindow addSubview:controllerView]; + [controllerView removeFromSuperview]; + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + XCTAssertLessThanOrEqual(completionCallCount, 1, + @"Observer should not fire after context is cleared"); + if (completionCallCount == 0) { + [resultExpectation fulfill]; + } + }); + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testObserverDelayAllowsDelegateMethodsToRunFirst { + id photoLibrary = OCMClassMock([PHPhotoLibrary class]); + OCMStub(ClassMethod([photoLibrary authorizationStatus])) + .andReturn(PHAuthorizationStatusAuthorized); + + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] init]; + UIImagePickerController *controller = [[UIImagePickerController alloc] init]; + [plugin setImagePickerControllerOverrides:@[ controller ]]; + + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; + __block NSInteger callCount = 0; + + [plugin pickImageWithSource:[FLTSourceSpecification makeWithType:FLTSourceTypeGallery + camera:FLTSourceCameraRear] + maxSize:[[FLTMaxSize alloc] init] + quality:nil + fullMetadata:NO + completion:^(NSString *_Nullable result, FlutterError *_Nullable error) { + callCount++; + if (callCount == 1) { + XCTAssertNil(result); + XCTAssertNil(error); + + UIView *controllerView = controller.view; + if (controllerView) { + UIWindow *testWindow = [[UIWindow alloc] init]; + [testWindow addSubview:controllerView]; + [controllerView removeFromSuperview]; + } + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + XCTAssertEqual( + callCount, 1, + @"Observer should not fire after context cleared by cancel"); + [resultExpectation fulfill]; + }); + } + }]; + + [plugin imagePickerControllerDidCancel:controller]; + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + @end diff --git a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m index 8ca93e706a9..32151bfba6b 100644 --- a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m +++ b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m @@ -27,6 +27,48 @@ - (instancetype)initWithResult:(nonnull FlutterResultAdapter)result { } @end +/** + * A callback function that is invoked when the PickerViewController is removed from the window. + * This callback is used to handle cleanup operations and notify the plugin when the picker + * interface has been dismissed, either through user interaction or programmatically. + */ +typedef void (^FLTImagePickerRemoveCallback)(void); + +/** + * A UIView subclass that monitors the removal of a PickerViewController from the window hierarchy. + * + * This observer view is added to the PickerViewController's view and monitors changes to its window + * property. When the PickerViewController is removed from the screen (either through user dismissal + * or programmatic dismissal), this view detects the change and triggers the associated callback. + * + * This mechanism ensures that the plugin receives notification when the picker is dismissed under + * various circumstances, including interactive dismissal gestures that occur before the + * PickerViewController has fully appeared on screen. + */ +@interface FLTImagePickerRemoveObserverView : UIView + +@property(nonatomic, copy, nonnull) FLTImagePickerRemoveCallback removeCallback; + +- (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback; + +@end + +@implementation FLTImagePickerRemoveObserverView + +- (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback { + if (self = [super init]) { + self.removeCallback = callback; + } + return self; +} +- (void)didMoveToWindow { + if (!self.window) { + [self removeFromSuperview]; + [[NSOperationQueue mainQueue] addOperationWithBlock:self.removeCallback]; + } +} +@end + #pragma mark - @interface FLTImagePickerPlugin () @@ -115,6 +157,7 @@ - (void)launchPHPickerWithContext:(nonnull FLTImagePickerMethodCallContext *)con pickerViewController.presentationController.delegate = self; self.callContext = context; + [self bindRemoveObserver:pickerViewController context:context]; [self showPhotoLibraryWithPHPicker:pickerViewController]; } @@ -138,6 +181,7 @@ - (void)launchUIImagePickerWithSource:(nonnull FLTSourceSpecification *)source self.callContext = context; + [self bindRemoveObserver:imagePickerController context:context]; switch (source.type) { case FLTSourceTypeCamera: [self checkCameraAuthorizationWithImagePicker:imagePickerController @@ -158,6 +202,19 @@ - (void)launchUIImagePickerWithSource:(nonnull FLTSourceSpecification *)source } } +- (void)bindRemoveObserver:(nonnull UIViewController *)controller + context:(nonnull FLTImagePickerMethodCallContext *)context { + __weak typeof(self) weakSelf = self; + FLTImagePickerRemoveObserverView *removeObserverView = + [[FLTImagePickerRemoveObserverView alloc] initWithRemoveCallback:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf && strongSelf.callContext == context && !strongSelf.isProcessingSelection) { + [strongSelf sendCallResultWithSavedPathList:nil]; + } + }]; + [controller.view addSubview:removeObserverView]; +} + #pragma mark - FLTImagePickerApi - (void)pickImageWithSource:(nonnull FLTSourceSpecification *)source @@ -464,6 +521,8 @@ - (NSNumber *)getDesiredImageQuality:(NSNumber *)imageQuality { #pragma mark - UIAdaptivePresentationControllerDelegate - (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { + self.isProcessingSelection = YES; + [self sendCallResultWithSavedPathList:nil]; } @@ -476,6 +535,7 @@ - (void)picker:(PHPickerViewController *)picker [self sendCallResultWithSavedPathList:nil]; return; } + self.isProcessingSelection = YES; __block NSOperationQueue *saveQueue = [[NSOperationQueue alloc] init]; saveQueue.name = @"Flutter Save Image Queue"; saveQueue.qualityOfService = NSQualityOfServiceUserInitiated; @@ -489,15 +549,13 @@ - (void)picker:(PHPickerViewController *)picker NSMutableArray *pathList = [[NSMutableArray alloc] initWithCapacity:results.count]; __block FlutterError *saveError = nil; __weak typeof(self) weakSelf = self; - // This operation will be executed on the main queue after - // all selected files have been saved. NSBlockOperation *sendListOperation = [NSBlockOperation blockOperationWithBlock:^{ if (saveError != nil) { [weakSelf sendCallResultWithError:saveError]; } else { [weakSelf sendCallResultWithSavedPathList:pathList]; } - // Retain queue until here. + weakSelf.isProcessingSelection = NO; saveQueue = nil; }]; @@ -522,8 +580,6 @@ - (void)picker:(PHPickerViewController *)picker [saveQueue addOperation:saveOperation]; }]; - // Schedule the final Flutter callback on the main queue - // to be run after all images have been saved. [NSOperationQueue.mainQueue addOperation:sendListOperation]; } @@ -531,6 +587,8 @@ - (void)picker:(PHPickerViewController *)picker - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { + self.isProcessingSelection = YES; + NSURL *videoURL = info[UIImagePickerControllerMediaURL]; [picker dismissViewControllerAnimated:YES completion:nil]; // The method dismissViewControllerAnimated does not immediately prevent @@ -538,6 +596,7 @@ - (void)imagePickerController:(UIImagePickerController *)picker // to prevent below code to be unwantly executed multiple times and cause a // crash. if (!self.callContext) { + self.isProcessingSelection = NO; return; } if (videoURL != nil) { @@ -618,6 +677,8 @@ - (void)imagePickerController:(UIImagePickerController *)picker } - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { + self.isProcessingSelection = YES; + [picker dismissViewControllerAnimated:YES completion:nil]; [self sendCallResultWithSavedPathList:nil]; } @@ -660,6 +721,7 @@ - (void)sendCallResultWithSavedPathList:(nullable NSArray *)pathList { self.callContext.result(pathList ?: [NSArray array], nil); } self.callContext = nil; + self.isProcessingSelection = NO; } /// Sends the given error via `callContext.result` as the result of the original platform channel @@ -672,6 +734,7 @@ - (void)sendCallResultWithError:(FlutterError *)error { } self.callContext.result(nil, error); self.callContext = nil; + self.isProcessingSelection = NO; } @end diff --git a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h index 404353d0120..c9292a99014 100644 --- a/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h +++ b/packages/image_picker/image_picker_ios/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h @@ -62,6 +62,9 @@ typedef void (^FlutterResultAdapter)(NSArray *_Nullable, FlutterErro /// The context of the Flutter method call that is currently being handled, if any. @property(strong, nonatomic, nullable) FLTImagePickerMethodCallContext *callContext; +/// Flag to track if we're currently processing a selection (to prevent observer from interfering) +@property(nonatomic, assign) BOOL isProcessingSelection; + - (UIViewController *)viewControllerWithWindow:(nullable UIWindow *)window; /// Validates the provided paths list, then sends it via `callContext.result` as the result of the diff --git a/packages/image_picker/image_picker_ios/pubspec.yaml b/packages/image_picker/image_picker_ios/pubspec.yaml index 30b0194fd16..001ffc8c0b3 100755 --- a/packages/image_picker/image_picker_ios/pubspec.yaml +++ b/packages/image_picker/image_picker_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_ios description: iOS implementation of the image_picker plugin. repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.13+3 +version: 0.8.14 environment: sdk: ^3.9.0