Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/image_picker/image_picker_ios/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,4 +701,209 @@ - (void)testPickMultiVideoWithoutLimit {
XCTAssertEqual(plugin.callContext.maxItemCount, 0);
}

#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<NSString *> *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];
}
Comment on lines 704 to 750

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test manually creates a removeCallback that duplicates the implementation logic from bindRemoveObserver:. This makes the test brittle; if the implementation changes, this test would need to be updated manually and might not fail correctly. It also doesn't test the new FLTImagePickerRemoveObserverView class and its didMoveToWindow trigger.

A better approach would be to write this as an integration test that calls one of the pick... methods to set up the real observer, and then simulates the dismissal to trigger it. This would provide more robust testing of the actual code path.

- (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] init];
    [testWindow addSubview:controller.view];
    [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<NSString *> *_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<NSString *> *_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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment on lines 30 to 40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation for FLTImagePickerRemoveCallback and FLTImagePickerRemoveObserverView could be improved for clarity and grammar. Clear documentation is important for long-term maintainability.

/**
 * A callback that is invoked when the picker view controller is removed from the window.
 */
typedef void (^FLTImagePickerRemoveCallback)(void);

/**
 * A view that uses `didMoveToWindow` to observe when it has been removed from a window.
 *
 * This is added to the picker's view hierarchy to detect when the picker has been dismissed
 * in cases where the standard delegate methods are not called (e.g., a quick interactive
 * dismissal).
 */

@interface FLTImagePickerRemoveObserverView : UIView

@property(nonatomic, copy, nonnull) FLTImagePickerRemoveCallback removeCallback;

-(instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback;

@end

@implementation FLTImagePickerRemoveObserverView

- (instancetype)initWithRemoveCallback:(FLTImagePickerRemoveCallback)callback{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's a minor style issue here: a missing space before the opening brace {. Adhering to a consistent code style improves readability.

- (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 ()
Expand Down Expand Up @@ -114,6 +148,7 @@ - (void)launchPHPickerWithContext:(nonnull FLTImagePickerMethodCallContext *)con
pickerViewController.presentationController.delegate = self;
self.callContext = context;

[self bindRemoveObserver:pickerViewController context:context];
[self showPhotoLibraryWithPHPicker:pickerViewController];
}

Expand All @@ -137,6 +172,7 @@ - (void)launchUIImagePickerWithSource:(nonnull FLTSourceSpecification *)source

self.callContext = context;

[self bindRemoveObserver:imagePickerController context:context];
switch (source.type) {
case FLTSourceTypeCamera:
[self checkCameraAuthorizationWithImagePicker:imagePickerController
Expand All @@ -157,6 +193,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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are a couple of minor style issues here (missing spaces) that deviate from the project's coding style. Consistent formatting improves code readability.

  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
Expand Down Expand Up @@ -475,6 +529,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;
Expand All @@ -496,6 +552,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;
}];
Expand Down Expand Up @@ -659,6 +717,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
Expand All @@ -671,6 +731,8 @@ - (void)sendCallResultWithError:(FlutterError *)error {
}
self.callContext.result(nil, error);
self.callContext = nil;
// Reset processing flag
self.isProcessingSelection = NO;
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ typedef void (^FlutterResultAdapter)(NSArray<NSString *> *_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
Expand Down