Repository navigation
[image_picker_ios] PR 4/5 Migrate the view provider, PHPicker save operation, and image utilities to Swift - #13394
Conversation
There was a problem hiding this comment.
Code Review
This pull request migrates the iOS implementation of the image_picker plugin from Objective-C to Swift, restructuring the codebase into mixed-language Swift Package Manager targets and updating the corresponding unit tests. Feedback on the changes highlights several critical improvements: ensuring thread safety by dispatching Flutter result callbacks on the main thread, avoiding potential crashes by safely copying immutable GIF metadata dictionaries before modification, and safely identifying video type identifiers. Additionally, several suggestions were made to align the Swift code with idiomatic practices, such as removing redundant synchronization, avoiding unnecessary force-unwrapping, and simplifying redundant helper calls.
| let typeIdentifier = result.itemProvider.registeredTypeIdentifiers.first | ||
| result.itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier ?? "") { |
There was a problem hiding this comment.
Using registeredTypeIdentifiers.first to load the video representation can be problematic if the first identifier is an image (e.g., for Live Photos or assets with multiple representations). It is safer to explicitly find the first identifier that conforms to the movie type.
let typeIdentifier = result.itemProvider.registeredTypeIdentifiers.first { identifier in
UTType(identifier)?.conforms(to: .movie) ?? false
} ?? UTType.movie.identifier
result.itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) {There was a problem hiding this comment.
processVideo only runs after the item has failed the image check and matched UTType.movie. This uses registeredTypeIdentifiers.first, which is the Swift form of the Objective-C firstObject. Live Photos take the image branch before this method runs, so I'm leaving the identifier selection as ported
| let gifProperties = | ||
| gifMetaProperties[kCGImagePropertyGIFDictionary] as? NSMutableDictionary | ||
| // Messaging a nil gifProperties is a no-op in Objective-C; keep that behavior. | ||
| gifProperties?[kCGImagePropertyGIFLoopCount] = 0 |
There was a problem hiding this comment.
Casting gifMetaProperties[kCGImagePropertyGIFDictionary] directly to NSMutableDictionary using as? NSMutableDictionary will fail for immutable dictionaries (which is what metaData usually contains). This prevents setting the loop count to 0. Creating a mutable copy of the dictionary resolves this issue.
| let gifProperties = | |
| gifMetaProperties[kCGImagePropertyGIFDictionary] as? NSMutableDictionary | |
| // Messaging a nil gifProperties is a no-op in Objective-C; keep that behavior. | |
| gifProperties?[kCGImagePropertyGIFLoopCount] = 0 | |
| if let existingGifProperties = gifMetaProperties[kCGImagePropertyGIFDictionary] as? NSDictionary, | |
| let gifProperties = existingGifProperties.mutableCopy() as? NSMutableDictionary { | |
| gifProperties[kCGImagePropertyGIFLoopCount] = 0 | |
| gifMetaProperties[kCGImagePropertyGIFDictionary] = gifProperties | |
| } |
There was a problem hiding this comment.
Done. ImageIO's nested GIF dictionary is immutable, so as? NSMutableDictionary was nil and the loop count was never written. The dictionary is copied, the loop count is set to 0, and that copy is written back. The GIF save tests expect that loop count
| func sendCallResult(withSavedPathList pathList: [String?]?) { | ||
| guard let callContext else { | ||
| return | ||
| } | ||
|
|
||
| if let pathList, pathList.contains(where: { $0 == nil }) { | ||
| callContext.result( | ||
| nil, | ||
| FlutterError( | ||
| code: "create_error", | ||
| message: "pathList's items should not be null", | ||
| details: nil)) | ||
| } else { | ||
| let paths = pathList?.compactMap { $0 } ?? [] | ||
| callContext.result(paths, nil) | ||
| } | ||
| self.callContext = nil | ||
| } | ||
|
|
||
| func sendCallResult(with error: FlutterError) { | ||
| guard let callContext else { | ||
| return | ||
| } | ||
| callContext.result(nil, error) | ||
| self.callContext = nil | ||
| } |
There was a problem hiding this comment.
The sendCallResult methods can be invoked from background threads (e.g., when retrieving full metadata via requestImageDataAndOrientation). Invoking Flutter's result callback on a background thread violates Flutter's threading model and can cause crashes or undefined behavior. Wrapping the bodies of both sendCallResult methods in DispatchQueue.main.async ensures thread safety.
func sendCallResult(withSavedPathList pathList: [String?]?) {
DispatchQueue.main.async {
guard let callContext = self.callContext else {
return
}
if let pathList, pathList.contains(where: { $0 == nil }) {
callContext.result(
nil,
FlutterError(
code: "create_error",
message: "pathList's items should not be null",
details: nil))
} else {
let paths = pathList?.compactMap { $0 } ?? []
callContext.result(paths, nil)
}
self.callContext = nil
}
}
func sendCallResult(with error: FlutterError) {
DispatchQueue.main.async {
guard let callContext = self.callContext else {
return
}
callContext.result(nil, error)
self.callContext = nil
}
}There was a problem hiding this comment.
This method is unchanged from the Swift plugin already on main. This PR only renamed the utility calls around it. The unit tests rely on sendCallResult invoking the callback synchronously on the calling thread. Wrapping it in DispatchQueue.main.async would change that for every picker result, so I'm not doing it in this migration
There was a problem hiding this comment.
This sounds like an issue. When you run it, is it on main thread? If not, can you add a TODO in the comment?
There was a problem hiding this comment.
The picker delegate and the PHPicker completion operation run on the main queue. requestImageDataAndOrientation's result handler is documented to run on an arbitrary queue, and that path calls sendCallResult when full metadata is requested. I added a TODO there. I left the callback synchronous so the unit tests can still read it on the calling thread
| objc_sync_enter(self) | ||
| let beingPresented = imagePickerController.isBeingPresented | ||
| objc_sync_exit(self) | ||
| if beingPresented { | ||
| return | ||
| } |
There was a problem hiding this comment.
Using objc_sync_enter(self) / objc_sync_exit(self) is unnecessary and unidiomatic in Swift here. Since showCamera is always called on the main thread, synchronization is redundant.
| objc_sync_enter(self) | |
| let beingPresented = imagePickerController.isBeingPresented | |
| objc_sync_exit(self) | |
| if beingPresented { | |
| return | |
| } | |
| if imagePickerController.isBeingPresented { | |
| return | |
| } |
There was a problem hiding this comment.
This is the port of the Objective-C @synchronized(self) around isBeingPresented. The permission callback already hops to the main queue before calling showCamera, and I'm keeping the same lock the previous implementation had
| if imageToScale == nil || width == 0 || height == 0 { | ||
| return nil | ||
| } | ||
| let imageToScale = imageToScale! |
There was a problem hiding this comment.
There was a problem hiding this comment.
Done. drawScaledImage now uses guard let and returns nil when the image is nil or either dimension is zero, matching the Objective-C check
| guard let image, let cgImage = image.cgImage else { | ||
| return drawScaledImage(nil, width: width, height: height) | ||
| } |
There was a problem hiding this comment.
Calling drawScaledImage(nil, ...) when image or cgImage is nil is redundant because drawScaledImage always returns nil when its first argument is nil. Returning nil directly is cleaner and more efficient.
| guard let image, let cgImage = image.cgImage else { | |
| return drawScaledImage(nil, width: width, height: height) | |
| } | |
| guard let image, let cgImage = image.cgImage else { | |
| return nil | |
| } |
There was a problem hiding this comment.
Done. A missing image or cgImage returns nil directly
…, and image utilities to Swift.
81d3f3e to
5112655
Compare
The generated Swift header still imports the umbrella while Pigeon remains Objective-C, and 0.8.13+13 is already on main.
This update ensures animated GIFs loop correctly when the source metadata is immutable. Additionally, it refines the image scaling logic by eliminating unnecessary force unwrapping and improving nil handling in the image drawing function.
hellohuanlin
left a comment
There was a problem hiding this comment.
Overall looks good. Left some comments
| (originalMetaDataJPG["ProfileName"] as? String) | ||
| == (newMetaDataJPG["ProfileName"] as? String) | ||
| (originalMetaDataJPG?["ProfileName"] as? String) | ||
| == (newMetaDataJPG?["ProfileName"] as? String) |
There was a problem hiding this comment.
just double check - if both are nil, should the test pass or fail? is nil metadata expected or unexpected?
There was a problem hiding this comment.
Nil is unexpected. Both fixtures have ProfileName sRGB IEC61966-2.1, but nil == nil used to pass. The test now requires the original profile name and still checks that the saved file has the same name. I made the same change for the PNG assertion below
|
|
||
| /// A `PickerItem` stand-in that does not require constructing a `PHPickerResult`. | ||
| final class FakePickerItem: NSObject, PickerItem { | ||
| @available(iOS 14, *) |
There was a problem hiding this comment.
You can delete this since image picker is ios 15+:
There was a problem hiding this comment.
Removed. The package deployment target is iOS 15, so @available(iOS 14, *) on FakePickerItem is redundant
| for asset: PHAsset, | ||
| options: PHImageRequestOptions?, | ||
| resultHandler: | ||
| @escaping (Data?, String?, CGImagePropertyOrientation, [AnyHashable: Any]?) -> Void |
There was a problem hiding this comment.
this is getting a bit long, can you typealias it and provide with parameter name for readability
There was a problem hiding this comment.
Done. The handler is now ImageDataHandler, with names for the image data, UTI, orientation, and info dictionary
| private static func drawScaledImage(_ imageToScale: UIImage?, width: Double, height: Double) | ||
| -> UIImage? | ||
| { | ||
| guard let imageToScale, width != 0, height != 0 else { |
There was a problem hiding this comment.
Done. drawScaledImage now returns nil unless width > 0 and height > 0
|
|
||
| private let kFirstByteJPEG: UInt8 = 0xFF | ||
| private let kFirstBytePNG: UInt8 = 0x89 | ||
| private let kFirstByteGIF: UInt8 = 0x47 |
There was a problem hiding this comment.
I know this is just migration, but is there any documentation or source for these values?
There was a problem hiding this comment.
These are the first bytes of the file signatures, carried over from the Objective-C constants. JPEG's SOI marker is 0xFFD8, PNG's signature starts with 0x89, and GIF's header starts with ASCII G (0x47). I added that above the constants
| if quality != nil && type != .jpeg { | ||
| let suffix = imageTypeSuffix(from: type) ?? "" | ||
| NSLog( | ||
| "image_picker: compressing is not supported for type %@. Returning the image with original quality", |
There was a problem hiding this comment.
is this log also from objc? should this be reported to dart side?
There was a problem hiding this comment.
Yes, this is the same NSLog from FLTImagePickerMetaDataUtil. It is not reported to Dart. For a non-JPEG type the image is still returned, PNG data as-is and other types as JPEG, and the caller saves that and returns the path. I would not add a new Dart error in this migration unless you want that behavior change
There was a problem hiding this comment.
Let's leave it but add a TODO then. This kind of error should be surfaced to developers so they can handle it.
There was a problem hiding this comment.
Done. I left the NSLog and added a TODO
| func sendCallResult(withSavedPathList pathList: [String?]?) { | ||
| guard let callContext else { | ||
| return | ||
| } | ||
|
|
||
| if let pathList, pathList.contains(where: { $0 == nil }) { | ||
| callContext.result( | ||
| nil, | ||
| FlutterError( | ||
| code: "create_error", | ||
| message: "pathList's items should not be null", | ||
| details: nil)) | ||
| } else { | ||
| let paths = pathList?.compactMap { $0 } ?? [] | ||
| callContext.result(paths, nil) | ||
| } | ||
| self.callContext = nil | ||
| } | ||
|
|
||
| func sendCallResult(with error: FlutterError) { | ||
| guard let callContext else { | ||
| return | ||
| } | ||
| callContext.result(nil, error) | ||
| self.callContext = nil | ||
| } |
There was a problem hiding this comment.
This sounds like an issue. When you run it, is it on main thread? If not, can you add a TODO in the comment?
| let suffix = imageTypeSuffix(from: type) ?? "" | ||
| // TODO: Surface this to Dart so callers can handle an unsupported | ||
| // compression request. This stays an `NSLog`, matching | ||
| // `FLTImagePickerMetaDataUtil`, and the image is still returned. |
There was a problem hiding this comment.
Can you remove This stays an NSLog, matching FLTImagePickerMetaDataUtil, and the image is still returned. - after migration, this class is gone, and the comment would be confusing
There was a problem hiding this comment.
Done. Removed that sentence
flutter/packages@0b72e92...3742a4e 2026-10-30 [email protected] [pigeon] Allow event channel unregistration (flutter/packages#13438) 2026-10-30 [email protected] [material_ui] Fix Escape key not closing MenuAnchor opened by tap or click (flutter/packages#13430) 2026-10-30 [email protected] manual roll and manual build and copy (flutter/packages#13495) 2026-10-29 [email protected] [pigeon] Separate native interop Dart bindings into a conditionally imported companion file (flutter/packages#13393) 2026-10-29 [email protected] [image_picker_ios] PR 4/5 Migrate the view provider, PHPicker save operation, and image utilities to Swift (flutter/packages#13394) 2026-10-29 [email protected] Sync release-material_ui-1.9.0 to main (flutter/packages#13479) 2026-10-29 [email protected] [material_ui] Unskip web tests that pass on CanvasKit and Skwasm: time_picker_test (flutter/packages#13471) 2026-10-29 [email protected] [vector_graphics] Dispose pictures decoded after their VectorGraphic is removed (flutter/packages#13435) 2026-10-29 [email protected] [camera_android_x] implement CameraX RecordingStats and VideoRecordEvent APIs (flutter/packages#13330) 2026-10-29 [email protected] Sync release-go_router-18.1.2 to main (flutter/packages#13480) 2026-10-29 [email protected] Sync release-cupertino_ui-1.1.5 to main (flutter/packages#13481) 2026-10-29 [email protected] [vector_graphics_compiler] Fix em units in text positions (flutter/packages#13388) If this roll has caused a breakage, revert this CL and set the roller to dry run mode using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC [email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
flutter/packages@0b72e92...3742a4e 2026-10-30 [email protected] [pigeon] Allow event channel unregistration (flutter/packages#13438) 2026-10-30 [email protected] [material_ui] Fix Escape key not closing MenuAnchor opened by tap or click (flutter/packages#13430) 2026-10-30 [email protected] manual roll and manual build and copy (flutter/packages#13495) 2026-10-29 [email protected] [pigeon] Separate native interop Dart bindings into a conditionally imported companion file (flutter/packages#13393) 2026-10-29 [email protected] [image_picker_ios] PR 4/5 Migrate the view provider, PHPicker save operation, and image utilities to Swift (flutter/packages#13394) 2026-10-29 [email protected] Sync release-material_ui-1.9.0 to main (flutter/packages#13479) 2026-10-29 [email protected] [material_ui] Unskip web tests that pass on CanvasKit and Skwasm: time_picker_test (flutter/packages#13471) 2026-10-29 [email protected] [vector_graphics] Dispose pictures decoded after their VectorGraphic is removed (flutter/packages#13435) 2026-10-29 [email protected] [camera_android_x] implement CameraX RecordingStats and VideoRecordEvent APIs (flutter/packages#13330) 2026-10-29 [email protected] Sync release-go_router-18.1.2 to main (flutter/packages#13480) 2026-10-29 [email protected] Sync release-cupertino_ui-1.1.5 to main (flutter/packages#13481) 2026-10-29 [email protected] [vector_graphics_compiler] Fix em units in text positions (flutter/packages#13388) If this roll has caused a breakage, revert this CL and set the roller to dry run mode using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC [email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
Resolves conflicts with flutter#13394, which replaced the Objective-C image utilities and the PHPicker save operation with Swift: - Moves this change to 0.8.13+15, above main's 0.8.13+14 entry. - Re-applies the fix to the new Swift helpers: the save methods return nil instead of writing an empty file or returning a path that was never written, the GIF writer no longer force-unwraps a destination that could not be created, and merging metadata returns nil when CGImageDestinationFinalize fails. The PHPicker operation and both UIImagePickerController paths report `invalid_image` when saving fails. - Makes the path-taking file and GIF writers internal so the tests can pass a path that cannot be written.
…tive-C to Swift migration. (#13493) Switches `image_picker_ios` Pigeon output from Objective-C to Swift and removes the remaining Objective-C packaging: * `pigeons/messages.dart` now generates `messages.g.swift` instead of `messages.g.h` and `messages.g.m`. * `ImagePickerPlugin` and the PHPicker save operation use the generated Swift API, including `PigeonError` instead of `FlutterError`. * Deletes the `image_picker_ios_objc` Swift Package Manager target, the `image_picker_ios.h` umbrella header, and the custom module map. * The CocoaPods pod is Swift-only and no longer sets `s.module_map`. The iOS example app and UI tests stay Objective-C until the next PR. Bumps image_picker_ios to **0.8.13+15**. PR 5/5 of the Obj-C → Swift migration, following #13394. Part of flutter/flutter#119107 ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
Migrates the Objective-C helpers that
ImagePickerPlugincalls into the Swift target:FIPViewProviderbecomesViewProvider/DefaultViewProvider.FLTPHPickerSaveImageToPathOperationbecomesPHPickerSaveImageToPathOperation.FLTImagePickerImageUtil,FLTImagePickerMetaDataUtil, andFLTImagePickerPhotoAssetUtilbecomeImagePickerImageUtil,ImagePickerMetaDataUtil, andImagePickerPhotoAssetUtil.PHImageManagerseam becomesImageDataRequesting.PHPickerResultconforms toPickerItem, replacing theBridgedPickerItemwrapper.Deletes the custom module map and
image_picker_ios.humbrella header. Theimage_picker_ios_objctarget remains for the Pigeon messages until the next PR. CocoaPods no longer setss.module_map.Bumps image_picker_ios to 0.8.13+14.
PR 4/5 of the Obj-C → Swift migration, stacked on #13235. Part of flutter/flutter#119107
Pre-Review Checklist
[shared_preferences]///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2