Skip to content

[image_picker] Migrate iOS components to Swift#11907

Open
sunil-kumartcs46 wants to merge 13 commits into
flutter:mainfrom
swift-migration-tcs:image_picker_updated_code
Open

[image_picker] Migrate iOS components to Swift#11907
sunil-kumartcs46 wants to merge 13 commits into
flutter:mainfrom
swift-migration-tcs:image_picker_updated_code

Conversation

@sunil-kumartcs46

Copy link
Copy Markdown

Migrated the iOS implementation from Objective-C to Swift with modern standards and added XCTests for native coverage.

Pre-Review Checklist

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-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.

Footnotes

  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. 2

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +413 to +421
if #available(iOS 14, *) {
if status == .authorized || status == .limited {
self?.showPhotoLibrary(with: pickerViewController)
} else {
self?.errorNoPhotoAccess(status)
}
} else {
// Fallback on earlier versions
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

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)
                    }

Comment on lines +16 to +48
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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
    }

Comment on lines +494 to +520
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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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
                    }
                }
            }

Comment on lines +659 to +663
}

func presentingViewControllerForImagePickerInNewWindow() -> UIViewController {
if let blocker = interactionBlockerWindow,
let rootVC = blocker.rootViewController

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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
        }
    }

Comment on lines +144 to +150
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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
        } ?? movieType

@sunil-kumartcs46 sunil-kumartcs46 force-pushed the image_picker_updated_code branch from bc89266 to 3d66c6e Compare June 15, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants