[image_picker] Migrate iOS components to Swift#11907
Conversation
There was a problem hiding this comment.
Code Review
This pull request migrates the image_picker_ios package and its example app from Objective-C to Swift, converting all source files, unit tests, and UI tests. The review feedback identifies several critical issues in the new Swift implementation: a missing authorization status fallback for iOS 13 or earlier that can cause the app to hang, an ignored isMetadataAvailable parameter in image scaling that leads to double-rotation bugs, a data race on shared variables during concurrent background save operations, unsafe background-thread execution of platform channel results in sendCallResult, and potential loading of incorrect media representations by blindly using the first registered type identifier.
| if #available(iOS 14, *) { | ||
| if status == .authorized || status == .limited { | ||
| self?.showPhotoLibrary(with: pickerViewController) | ||
| } else { | ||
| self?.errorNoPhotoAccess(status) | ||
| } | ||
| } else { | ||
| // Fallback on earlier versions | ||
| } |
There was a problem hiding this comment.
On iOS 13 or earlier, the #available(iOS 14, *) check is false, so the else block is executed. Since the else block is empty, requesting photo library authorization on iOS 13 or earlier does nothing when the user grants or denies permission, causing the app to hang indefinitely. We should handle the authorization status fallback for iOS 13 or earlier.
let authorized: Bool
if #available(iOS 14, *) {
authorized = status == .authorized || status == .limited
} else {
authorized = status == .authorized
}
if authorized {
self?.showPhotoLibrary(with: pickerViewController)
} else {
self?.errorNoPhotoAccess(status)
}| static func scaledImage( | ||
| _ image: UIImage, | ||
| maxWidth: Double?, | ||
| maxHeight: Double?, | ||
| isMetadataAvailable _: Bool | ||
| ) -> UIImage { | ||
| let originalWidth = image.size.width | ||
| let originalHeight = image.size.height | ||
|
|
||
| let shouldDownscaleWidth = maxWidth.map { $0 < originalWidth } ?? false | ||
| let shouldDownscaleHeight = maxHeight.map { $0 < originalHeight } ?? false | ||
| let shouldDownscale = shouldDownscaleWidth || shouldDownscaleHeight | ||
|
|
||
| if !shouldDownscale { | ||
| return image | ||
| } | ||
|
|
||
| let aspectRatio = originalWidth / originalHeight | ||
|
|
||
| var width = maxWidth.map { round($0) } ?? originalWidth | ||
| var height = maxHeight.map { round($0) } ?? originalHeight | ||
|
|
||
| let widthForMaxHeight = height * aspectRatio | ||
| let heightForMaxWidth = width / aspectRatio | ||
|
|
||
| if heightForMaxWidth > height { | ||
| width = round(widthForMaxHeight) | ||
| } else { | ||
| height = round(heightForMaxWidth) | ||
| } | ||
|
|
||
| return drawScaledImage(image, width: width, height: height) ?? image | ||
| } |
There was a problem hiding this comment.
The isMetadataAvailable parameter is currently ignored (using _), which bypasses the orientation correction logic from the original Objective-C implementation. When isMetadataAvailable is true, scaling an image with orientation metadata (like .right or .left) without setting its orientation to .up and swapping the dimensions will cause the orientation to be baked into the scaled image while still retaining the original orientation metadata. This leads to double-rotation bugs when the image is viewed.
static func scaledImage(
_ image: UIImage,
maxWidth: Double?,
maxHeight: Double?,
isMetadataAvailable: Bool
) -> UIImage {
let originalWidth = image.size.width
let originalHeight = image.size.height
let shouldDownscaleWidth = maxWidth.map { $0 < originalWidth } ?? false
let shouldDownscaleHeight = maxHeight.map { $0 < originalHeight } ?? false
let shouldDownscale = shouldDownscaleWidth || shouldDownscaleHeight
if !shouldDownscale {
return image
}
let aspectRatio = originalWidth / originalHeight
var width = maxWidth.map { round($0) } ?? originalWidth
var height = maxHeight.map { round($0) } ?? originalHeight
let widthForMaxHeight = height * aspectRatio
let heightForMaxWidth = width / aspectRatio
if heightForMaxWidth > height {
width = round(widthForMaxHeight)
} else {
height = round(heightForMaxWidth)
}
guard let cgImage = image.cgImage else {
return image
}
let imageToScale: UIImage
var finalWidth = width
var finalHeight = height
if !isMetadataAvailable {
imageToScale = UIImage(cgImage: cgImage, scale: 1, orientation: image.imageOrientation)
} else {
imageToScale = UIImage(cgImage: cgImage, scale: 1, orientation: .up)
if image.imageOrientation == .left ||
image.imageOrientation == .right ||
image.imageOrientation == .leftMirrored ||
image.imageOrientation == .rightMirrored {
let temp = finalWidth
finalWidth = finalHeight
finalHeight = temp
}
}
return drawScaledImage(imageToScale, width: finalWidth, height: finalHeight) ?? image
}| var pathList = [String?](repeating: nil, count: results.count) | ||
| var saveError: Error? | ||
|
|
||
| let sendListOperation = BlockOperation { | ||
| DispatchQueue.main.async { | ||
| if let error = saveError { | ||
| self.sendCallResult(error: error) | ||
| } else { | ||
| self.sendCallResult(pathList: pathList.compactMap { $0 }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| for (index, result) in results.enumerated() { | ||
| let saveOperation = PHPickerSaveImageToPathOperation( | ||
| itemProvider: result.itemProvider, | ||
| maxHeight: maxHeight, | ||
| maxWidth: maxWidth, | ||
| desiredImageQuality: desiredImageQuality, | ||
| fullMetadata: requestFullMetadata | ||
| ) { savedPath, error in | ||
| if let savedPath = savedPath { | ||
| pathList[index] = savedPath | ||
| } else { | ||
| saveError = error | ||
| } | ||
| } |
There was a problem hiding this comment.
There is a data race on pathList and saveError because multiple PHPickerSaveImageToPathOperation instances run concurrently on a background queue and write to these shared variables from different threads. To prevent data races and undefined behavior, synchronize access to these variables using a serial dispatch queue.
var pathList = [String?](repeating: nil, count: results.count)
var saveError: Error?
let synchronizationQueue = DispatchQueue(label: "dev.flutter.image_picker.synchronization")
let sendListOperation = BlockOperation {
var finalPathList: [String] = []
var finalError: Error?
synchronizationQueue.sync {
finalPathList = pathList.compactMap { $0 }
finalError = saveError
}
DispatchQueue.main.async {
if let error = finalError {
self.sendCallResult(error: error)
} else {
self.sendCallResult(pathList: finalPathList)
}
}
}
for (index, result) in results.enumerated() {
let saveOperation = PHPickerSaveImageToPathOperation(
itemProvider: result.itemProvider,
maxHeight: maxHeight,
maxWidth: maxWidth,
desiredImageQuality: desiredImageQuality,
fullMetadata: requestFullMetadata
) { savedPath, error in
synchronizationQueue.sync {
if let savedPath = savedPath {
pathList[index] = savedPath
} else {
saveError = error
}
}
}| } | ||
|
|
||
| func presentingViewControllerForImagePickerInNewWindow() -> UIViewController { | ||
| if let blocker = interactionBlockerWindow, | ||
| let rootVC = blocker.rootViewController |
There was a problem hiding this comment.
sendCallResult can be invoked from background threads (e.g., from PHImageManager callbacks or background save operations). Invoking context.result on a background thread violates Flutter's requirement that platform channel communication must occur on the main thread, which can cause crashes. Additionally, concurrent access to callContext from multiple threads is not safe. Wrapping the entire body in DispatchQueue.main.async ensures thread safety and main-thread execution.
func sendCallResult(pathList: [String]? = nil, error: Error? = nil) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let context = self.callContext else { return }
context.result(pathList, error)
self.callContext = nil
}
}| guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first else { | ||
| let error = PigeonError( | ||
| code: "invalid_source", message: "No registered type identifiers.", details: nil | ||
| ) | ||
| completeOperation(path: nil, error: error) | ||
| return | ||
| } |
There was a problem hiding this comment.
Using itemProvider.registeredTypeIdentifiers.first blindly can result in loading the wrong representation (e.g., loading a JPEG image instead of the video) for items with multiple representations, such as Live Photos. Since we already know we want to process a video, we should find the type identifier that actually conforms to UTType.movie.
let movieType = UTType.movie.identifier
let typeIdentifier = itemProvider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .movie) ?? false
} ?? movieTypebc89266 to
3d66c6e
Compare
Migrated the iOS implementation from Objective-C to Swift with modern standards and added XCTests for native coverage.
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