diff --git a/packages/image_picker/image_picker_ios/CHANGELOG.md b/packages/image_picker/image_picker_ios/CHANGELOG.md index db9303fbf8b..fb0b037f05b 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 picking methods infinitely awaiting when the native picker was closed quickly. + ## 0.8.13+2 * Updates to Pigeon 26. 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 6671f4f6843..62a5adc3c28 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 @@ -701,4 +701,215 @@ - (void)testPickMultiVideoWithoutLimit { XCTAssertEqual(plugin.callContext.maxItemCount, 0); } +- (void)testUIImagePickerImmediateCloseReturnsEmptyArray { + FLTImagePickerPlugin *plugin = [[FLTImagePickerPlugin alloc] init]; + + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; + + FLTImagePickerMethodCallContext *context = [[FLTImagePickerMethodCallContext alloc] + initWithResult:^void(NSArray *paths, FlutterError *error) { + if (paths == nil || paths.count == 0) { + XCTAssertNil(error); + [resultExpectation fulfill]; + } + }]; + context.includeImages = YES; + context.maxSize = [[FLTMaxSize alloc] init]; + context.maxItemCount = 1; + context.requestFullMetadata = NO; + + plugin.callContext = context; + + UIImagePickerController *controller = [[UIImagePickerController alloc] init]; + UIView *controllerView = controller.view; + + UIView *observerView = [[UIView alloc] init]; + [controllerView addSubview:observerView]; + + void (^removeCallback)(void) = ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if (plugin && plugin.callContext == context && !plugin.isProcessingSelection) { + [plugin sendCallResultWithSavedPathList:nil]; + } + }); + }; + + UIWindow *testWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + testWindow.hidden = NO; + [testWindow addSubview:controllerView]; + + [testWindow setNeedsLayout]; + [testWindow layoutIfNeeded]; + + [controllerView removeFromSuperview]; + + removeCallback(); + + [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 78918e7edc7..28b7e444b1a 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,41 @@ - (instancetype)initWithResult:(nonnull FlutterResultAdapter)result { } @end +/** + * a callback function what the PickerViewController remove from window. + */ +typedef void (^FLTImagePickerRemoveCallback)(void); + +/** + * Add the view to the PickerViewController's view, observing its window to observe the window of + * PickerViewController. This is to prevent PickerViewController from being removed from the screen + * without receiving callback information under other circumstances, such as being interactively + * dismissed before PickerViewController has fully popped up. + */ +@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 () @@ -114,6 +149,7 @@ - (void)launchPHPickerWithContext:(nonnull FLTImagePickerMethodCallContext *)con pickerViewController.presentationController.delegate = self; self.callContext = context; + [self bindRemoveObserver:pickerViewController context:context]; [self showPhotoLibraryWithPHPicker:pickerViewController]; } @@ -137,6 +173,7 @@ - (void)launchUIImagePickerWithSource:(nonnull FLTSourceSpecification *)source self.callContext = context; + [self bindRemoveObserver:imagePickerController context:context]; switch (source.type) { case FLTSourceTypeCamera: [self checkCameraAuthorizationWithImagePicker:imagePickerController @@ -157,6 +194,25 @@ - (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; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if (strongSelf && strongSelf.callContext == context && + !strongSelf.isProcessingSelection) { + // Only send result if context is still active and we're not processing a + // selection + [strongSelf sendCallResultWithSavedPathList:nil]; + } + }); + }]; + [controller.view addSubview:removeObserverView]; +} + #pragma mark - FLTImagePickerApi - (void)pickImageWithSource:(nonnull FLTSourceSpecification *)source @@ -475,6 +531,8 @@ - (void)picker:(PHPickerViewController *)picker [self sendCallResultWithSavedPathList:nil]; return; } + // Mark that we're processing a selection to prevent observer from interfering + self.isProcessingSelection = YES; __block NSOperationQueue *saveQueue = [[NSOperationQueue alloc] init]; saveQueue.name = @"Flutter Save Image Queue"; saveQueue.qualityOfService = NSQualityOfServiceUserInitiated; @@ -496,6 +554,8 @@ - (void)picker:(PHPickerViewController *)picker } else { [weakSelf sendCallResultWithSavedPathList:pathList]; } + // Clear the processing flag after sending result + weakSelf.isProcessingSelection = NO; // Retain queue until here. saveQueue = nil; }]; @@ -659,6 +719,8 @@ - (void)sendCallResultWithSavedPathList:(nullable NSArray *)pathList { self.callContext.result(pathList ?: [NSArray array], nil); } self.callContext = nil; + // Reset processing flag + self.isProcessingSelection = NO; } /// Sends the given error via `callContext.result` as the result of the original platform channel @@ -671,6 +733,8 @@ - (void)sendCallResultWithError:(FlutterError *)error { } self.callContext.result(nil, error); self.callContext = nil; + // Reset processing flag + 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 f91784c872c..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+2 +version: 0.8.14 environment: sdk: ^3.9.0