From d9e5785d52b8a0fd9ad19580e6dffffa01d8dc20 Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Tue, 18 Nov 2025 12:23:59 +0530 Subject: [PATCH 1/7] infinite await in picking methods when native picker is closed quickly - fixes #134715 --- .../image_picker_ios/CHANGELOG.md | 4 + .../ios/RunnerTests/ImagePickerPluginTests.m | 205 ++++++++++++++++++ .../image_picker_ios/FLTImagePickerPlugin.m | 62 ++++++ .../FLTImagePickerPlugin_Test.h | 3 + 4 files changed, 274 insertions(+) diff --git a/packages/image_picker/image_picker_ios/CHANGELOG.md b/packages/image_picker/image_picker_ios/CHANGELOG.md index a8b8c31a14f..a89ab968233 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+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..8ade76619a6 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,209 @@ - (void)testPickVideoSetsCurrentRepresentationMode API_AVAILABLE(ios(14)) { OCMVerifyAll(mockPickerViewController); } +#pragma mark - Test immediate picker close detection + +- (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 8ca93e706a9..d8030e6defb 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,40 @@ - (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 () @@ -115,6 +149,7 @@ - (void)launchPHPickerWithContext:(nonnull FLTImagePickerMethodCallContext *)con pickerViewController.presentationController.delegate = self; self.callContext = context; + [self bindRemoveObserver:pickerViewController context:context]; [self showPhotoLibraryWithPHPicker:pickerViewController]; } @@ -138,6 +173,7 @@ - (void)launchUIImagePickerWithSource:(nonnull FLTSourceSpecification *)source self.callContext = context; + [self bindRemoveObserver:imagePickerController context:context]; switch (source.type) { case FLTSourceTypeCamera: [self checkCameraAuthorizationWithImagePicker:imagePickerController @@ -158,6 +194,24 @@ - (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; + // Add a small delay to ensure delegate methods have a chance to run first + // This prevents the observer from firing during normal selection flow + 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 @@ -476,6 +530,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; @@ -497,6 +553,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; }]; @@ -660,6 +718,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 @@ -672,6 +732,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 From ca444099f54d271d9eb76a1d8041cd99cf0a88b1 Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Tue, 18 Nov 2025 13:01:24 +0530 Subject: [PATCH 2/7] Version bump --- packages/image_picker/image_picker_ios/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 1449a475d7c05c5cae5946c7049abbf67ae11b3a Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Tue, 18 Nov 2025 14:55:17 +0530 Subject: [PATCH 3/7] Code format --- .../image_picker_ios/FLTImagePickerPlugin.m | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) 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 d8030e6defb..567614f19d9 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 @@ -33,21 +33,22 @@ - (instancetype)initWithResult:(nonnull FlutterResultAdapter)result { 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. + * 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; +- (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback; @end @implementation FLTImagePickerRemoveObserverView -- (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback{ +- (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback { if (self = [super init]) { self.removeCallback = callback; } @@ -56,7 +57,7 @@ - (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback{ - (void)didMoveToWindow { if (!self.window) { [self removeFromSuperview]; - [[NSOperationQueue mainQueue]addOperationWithBlock:self.removeCallback]; + [[NSOperationQueue mainQueue] addOperationWithBlock:self.removeCallback]; } } @end @@ -198,16 +199,17 @@ - (void)bindRemoveObserver:(nonnull UIViewController *)controller context:(nonnull FLTImagePickerMethodCallContext *)context { __weak typeof(self) weakSelf = self; FLTImagePickerRemoveObserverView *removeObserverView = - [[FLTImagePickerRemoveObserverView alloc]initWithRemoveCallback:^{ + [[FLTImagePickerRemoveObserverView alloc] initWithRemoveCallback:^{ __strong typeof(weakSelf) strongSelf = weakSelf; - // Add a small delay to ensure delegate methods have a chance to run first - // This prevents the observer from firing during normal selection flow - 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]; - } - }); + 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]; } From 484408ea8741d415bceff0764cd9c635c84a5e4a Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Wed, 26 Nov 2025 11:35:58 +0530 Subject: [PATCH 4/7] Address gemini comments --- .../ios/RunnerTests/ImagePickerPluginTests.m | 48 ++++++++++++------- .../image_picker_ios/FLTImagePickerPlugin.m | 19 ++++++-- 2 files changed, 44 insertions(+), 23 deletions(-) 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 8ade76619a6..22efa6b34d5 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 @@ -730,28 +730,40 @@ - (void)testPickVideoSetsCurrentRepresentationMode API_AVAILABLE(ios(14)) { - (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; + UIImagePickerController *controller = [[UIImagePickerController alloc] init]; + [plugin setImagePickerControllerOverrides:@[ controller ]]; - plugin.callContext = context; + // 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); - UIImagePickerController *controller = [[UIImagePickerController alloc] init]; - UIView *controllerView = controller.view; + XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"]; - UIView *observerView = [[UIView alloc] init]; - [controllerView addSubview:observerView]; + 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] init]; + [testWindow addSubview:controller.view]; + [controller.view removeFromSuperview]; + }); 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) { 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 567614f19d9..6120fb576d4 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 @@ -28,15 +28,22 @@ - (instancetype)initWithResult:(nonnull FlutterResultAdapter)result { @end /** - * a callback function what the PickerViewController remove from window. + * 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); /** - * 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. + * 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 @@ -201,6 +208,8 @@ - (void)bindRemoveObserver:(nonnull UIViewController *)controller FLTImagePickerRemoveObserverView *removeObserverView = [[FLTImagePickerRemoveObserverView alloc] initWithRemoveCallback:^{ __strong typeof(weakSelf) strongSelf = weakSelf; + // Add a small delay to ensure delegate methods have a chance to run first + // This prevents the observer from firing during normal selection flow dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ if (strongSelf && strongSelf.callContext == context && From fc797737753e829ff9b5c4c124130b036a4b48ec Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Tue, 2 Dec 2025 12:26:51 +0530 Subject: [PATCH 5/7] code format --- .../example/ios/RunnerTests/ImagePickerPluginTests.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 22efa6b34d5..c914cae6185 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 @@ -735,9 +735,11 @@ - (void)testUIImagePickerImmediateCloseReturnsEmptyArray { // Mock camera access to avoid permission dialogs and device-specific logic. id mockUIImagePicker = OCMClassMock([UIImagePickerController class]); - OCMStub(ClassMethod([mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + OCMStub(ClassMethod( + [mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) .andReturn(YES); - OCMStub(ClassMethod([mockUIImagePicker isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceRear])) + OCMStub(ClassMethod( + [mockUIImagePicker isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceRear])) .andReturn(YES); id mockAVCaptureDevice = OCMClassMock([AVCaptureDevice class]); OCMStub([mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) From bacbdadabace90b6b9b85dfbc7ede31e9b159a4d Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Tue, 2 Dec 2025 13:00:19 +0530 Subject: [PATCH 6/7] code format --- .../Sources/image_picker_ios/FLTImagePickerPlugin.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6120fb576d4..00c2f420760 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 @@ -36,11 +36,11 @@ - (instancetype)initWithResult:(nonnull FlutterResultAdapter)result { /** * 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. From 67d7992acfef8382d96309403778e6a1648fcfd6 Mon Sep 17 00:00:00 2001 From: Raju M <24muliyashiya@gmail.com> Date: Mon, 8 Dec 2025 13:03:01 +0530 Subject: [PATCH 7/7] determine the state without timer --- .../image_picker_ios/CHANGELOG.md | 2 +- .../ios/RunnerTests/ImagePickerPluginTests.m | 65 +++++++++---------- .../image_picker_ios/FLTImagePickerPlugin.m | 30 +++------ 3 files changed, 41 insertions(+), 56 deletions(-) diff --git a/packages/image_picker/image_picker_ios/CHANGELOG.md b/packages/image_picker/image_picker_ios/CHANGELOG.md index a89ab968233..d39c4ec88ea 100644 --- a/packages/image_picker/image_picker_ios/CHANGELOG.md +++ b/packages/image_picker/image_picker_ios/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.8.14 -* Fixes picking methods infinitely awaiting when the native picker was closed quickly. +* Fixes Future not completing when image picker is dismissed quickly before fully appearing. ## 0.8.13+3 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 c914cae6185..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 @@ -762,28 +762,16 @@ - (void)testUIImagePickerImmediateCloseReturnsEmptyArray { // 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] init]; + UIWindow *testWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + testWindow.hidden = NO; [testWindow addSubview:controller.view]; - [controller.view removeFromSuperview]; - }); - 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]; - [testWindow setNeedsLayout]; - [testWindow layoutIfNeeded]; - - [controllerView removeFromSuperview]; - - removeCallback(); + // Simulate the picker being removed from the window hierarchy + [controller.view removeFromSuperview]; + }); [self waitForExpectationsWithTimeout:1.0 handler:nil]; } @@ -849,11 +837,13 @@ - (void)testObserverDoesNotInterfereWhenProcessingSelection API_AVAILABLE(ios(14 [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"); - } - }); + 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]; } @@ -891,13 +881,14 @@ - (void)testObserverRespectsContextClearing { [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]; - } - }); + 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]; } @@ -932,10 +923,14 @@ - (void)testObserverDelayAllowsDelegateMethodsToRunFirst { [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]; - }); + 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]; + }); } }]; 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 00c2f420760..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 @@ -208,17 +208,9 @@ - (void)bindRemoveObserver:(nonnull UIViewController *)controller FLTImagePickerRemoveObserverView *removeObserverView = [[FLTImagePickerRemoveObserverView alloc] initWithRemoveCallback:^{ __strong typeof(weakSelf) strongSelf = weakSelf; - // Add a small delay to ensure delegate methods have a chance to run first - // This prevents the observer from firing during normal selection flow - 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]; - } - }); + if (strongSelf && strongSelf.callContext == context && !strongSelf.isProcessingSelection) { + [strongSelf sendCallResultWithSavedPathList:nil]; + } }]; [controller.view addSubview:removeObserverView]; } @@ -529,6 +521,8 @@ - (NSNumber *)getDesiredImageQuality:(NSNumber *)imageQuality { #pragma mark - UIAdaptivePresentationControllerDelegate - (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { + self.isProcessingSelection = YES; + [self sendCallResultWithSavedPathList:nil]; } @@ -541,7 +535,6 @@ - (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"; @@ -556,17 +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]; } - // Clear the processing flag after sending result weakSelf.isProcessingSelection = NO; - // Retain queue until here. saveQueue = nil; }]; @@ -591,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]; } @@ -600,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 @@ -607,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) { @@ -687,6 +677,8 @@ - (void)imagePickerController:(UIImagePickerController *)picker } - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { + self.isProcessingSelection = YES; + [picker dismissViewControllerAnimated:YES completion:nil]; [self sendCallResultWithSavedPathList:nil]; } @@ -729,7 +721,6 @@ - (void)sendCallResultWithSavedPathList:(nullable NSArray *)pathList { self.callContext.result(pathList ?: [NSArray array], nil); } self.callContext = nil; - // Reset processing flag self.isProcessingSelection = NO; } @@ -743,7 +734,6 @@ - (void)sendCallResultWithError:(FlutterError *)error { } self.callContext.result(nil, error); self.callContext = nil; - // Reset processing flag self.isProcessingSelection = NO; }