import UIKit import AVFoundation import ExpoModulesCore protocol CameraPhotoCaptureDelegate: AnyObject { var appContext: AppContext? { get } var previewLayer: AVCaptureVideoPreviewLayer { get } var deviceOrientation: UIInterfaceOrientation { get } var responsiveWhenOrientationLocked: Bool { get } var physicalOrientation: UIDeviceOrientation { get } var presetCamera: AVCaptureDevice.Position { get } var mirror: Bool { get } var flashMode: FlashMode { get } var onPictureSaved: EventDispatcher { get } } class CameraPhotoCapture: NSObject, AVCapturePhotoCaptureDelegate { weak var captureDelegate: CameraPhotoCaptureDelegate? private var photoCaptureOptions: TakePictureOptions? private var photoCapturedContinuation: CheckedContinuation? init(delegate: CameraPhotoCaptureDelegate) { self.captureDelegate = delegate super.init() } func takePictureRef(options: TakePictureOptions, photoOutput: AVCapturePhotoOutput) async throws -> PictureRef { let optionsCopy = options optionsCopy.pictureRef = true let result = try await takePicture(options: optionsCopy, photoOutput: photoOutput) if let ref = result as? PictureRef { return ref } throw CameraImageCaptureException() } func takePicturePromise(options: TakePictureOptions, photoOutput: AVCapturePhotoOutput) async throws -> [String: Any] { let optionsCopy = options optionsCopy.pictureRef = false let result = try await takePicture(options: optionsCopy, photoOutput: photoOutput) if let result = result as? [String: Any] { return result } throw CameraImageCaptureException() } func takePicture(options: TakePictureOptions, photoOutput: AVCapturePhotoOutput) async throws -> Any { guard photoCapturedContinuation == nil else { throw CameraNotReadyException() } return try await withCheckedThrowingContinuation { continuation in photoCapturedContinuation = continuation photoCaptureOptions = options let connection = photoOutput.connection(with: .video) let orientation = captureDelegate?.responsiveWhenOrientationLocked == true ? captureDelegate?.physicalOrientation ?? .unknown : UIDevice.current.orientation connection?.videoOrientation = ExpoCameraUtils.videoOrientation(for: orientation) // options.mirror is deprecated but should continue to work until removed connection?.isVideoMirrored = captureDelegate?.presetCamera == .front && ((captureDelegate?.mirror ?? false) || options.mirror) var photoSettings = AVCapturePhotoSettings() if photoOutput.availablePhotoCodecTypes.contains(AVVideoCodecType.hevc) { photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) } let requestedFlashMode = captureDelegate?.flashMode.toDeviceFlashMode() ?? .auto if photoOutput.supportedFlashModes.contains(requestedFlashMode) { photoSettings.flashMode = requestedFlashMode } if #available(iOS 16.0, *) { photoSettings.maxPhotoDimensions = photoOutput.maxPhotoDimensions } if !photoSettings.availablePreviewPhotoPixelFormatTypes.isEmpty, let previewFormat = photoSettings.__availablePreviewPhotoPixelFormatTypes.first { photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: previewFormat] } if photoOutput.isHighResolutionCaptureEnabled { photoSettings.isHighResolutionPhotoEnabled = true } photoSettings.photoQualityPrioritization = .balanced photoOutput.capturePhoto(with: photoSettings, delegate: self) } } func photoOutput(_ output: AVCapturePhotoOutput, willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings) { if photoCaptureOptions?.shutterSound == false { AudioServicesDisposeSystemSoundID(1108) } } func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error? ) { guard let options = photoCaptureOptions, let continuation = photoCapturedContinuation else { return } photoCapturedContinuation = nil photoCaptureOptions = nil if error != nil { continuation.resume(throwing: CameraImageCaptureException()) return } do { let result = try processImageData( imageData: photo.fileDataRepresentation(), metadata: photo.metadata, options: options ) continuation.resume(returning: result) } catch { continuation.resume(throwing: error) } } private func processImageData( imageData: Data?, metadata: [String: Any], options: TakePictureOptions ) throws -> Any { guard let captureDelegate else { throw CameraSavingImageException("Photo Capture delegate unavailable") } guard let imageData, var takenImage = UIImage(data: imageData) else { throw CameraSavingImageException("Failed to process image data") } let previewSize = if captureDelegate.deviceOrientation == .portrait { CGSize(width: captureDelegate.previewLayer.frame.size.height, height: captureDelegate.previewLayer.frame.size.width) } else { CGSize(width: captureDelegate.previewLayer.frame.size.width, height: captureDelegate.previewLayer.frame.size.height) } guard let takenCgImage = takenImage.cgImage else { throw CameraSavingImageException("Failed to get CGImage") } let cropRect = CGRect(x: 0, y: 0, width: takenCgImage.width, height: takenCgImage.height) let croppedSize = AVMakeRect(aspectRatio: previewSize, insideRect: cropRect) takenImage = ExpoCameraUtils.crop(image: takenImage, to: croppedSize) let width = takenImage.size.width let height = takenImage.size.height var processedImageData: Data? var response = [String: Any]() if options.exif { guard let exifDict = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any] else { throw CameraSavingImageException("Failed to process EXIF data") } var updatedExif = ExpoCameraUtils.updateExif( metadata: exifDict, with: ["Orientation": ExpoCameraUtils.toExifOrientation(orientation: takenImage.imageOrientation)] ) updatedExif[kCGImagePropertyExifPixelYDimension as String] = width updatedExif[kCGImagePropertyExifPixelXDimension as String] = height response["exif"] = updatedExif var updatedMetadata = metadata if let additionalExif = options.additionalExif { for (key, value) in additionalExif { updatedExif[key] = value } let gpsDict = createGPSDict(additionalExif: options.additionalExif) if updatedMetadata[kCGImagePropertyGPSDictionary as String] == nil { updatedMetadata[kCGImagePropertyGPSDictionary as String] = gpsDict } else if var existingGpsDict = updatedMetadata[kCGImagePropertyGPSDictionary as String] as? [String: Any] { existingGpsDict.merge(gpsDict) { _, new in new } updatedMetadata[kCGImagePropertyGPSDictionary as String] = existingGpsDict } } updatedMetadata[kCGImagePropertyExifDictionary as String] = updatedExif processedImageData = ExpoCameraUtils.data( from: takenImage, with: updatedMetadata, quality: Float(options.quality)) } else { if options.imageType == .png { processedImageData = takenImage.pngData() } else { processedImageData = takenImage.jpegData(compressionQuality: options.quality) } } guard let processedImageData else { throw CameraSavingImageException("Image data could not be processed") } if options.pictureRef { if let image = UIImage(data: processedImageData) { return PictureRef(image) } throw CameraSavingImageException("Failed to create UIImage from processed data") } let path = FileSystemUtilities.generatePathInCache( captureDelegate.appContext, in: "Camera", extension: options.imageType.toExtension() ) response["uri"] = ExpoCameraUtils.write(data: processedImageData, to: path) response["width"] = width response["height"] = height response["format"] = options.imageType.rawValue if options.base64 { response["base64"] = processedImageData.base64EncodedString() } if options.fastMode { captureDelegate.onPictureSaved(["data": response, "id": options.id]) return () } return response } func cleanup() { photoCapturedContinuation?.resume(throwing: CameraUnmountedException()) self.photoCapturedContinuation = nil } private func createGPSDict(additionalExif: [String: Any]?) -> [String: Any] { guard let additionalExif else { return [:] } var gpsDict = [String: Any]() if let latitude = additionalExif["GPSLatitude"], let latValue = toDouble(latitude) { gpsDict[kCGImagePropertyGPSLatitude as String] = abs(latValue) gpsDict[kCGImagePropertyGPSLatitudeRef as String] = latValue >= 0 ? "N" : "S" } if let longitude = additionalExif["GPSLongitude"], let lonValue = toDouble(longitude) { gpsDict[kCGImagePropertyGPSLongitude as String] = abs(lonValue) gpsDict[kCGImagePropertyGPSLongitudeRef as String] = lonValue >= 0 ? "E" : "W" } if let altitude = additionalExif["GPSAltitude"], let altValue = toDouble(altitude) { gpsDict[kCGImagePropertyGPSAltitude as String] = abs(altValue) gpsDict[kCGImagePropertyGPSAltitudeRef as String] = altValue >= 0 ? 0 : 1 } if let speed = additionalExif["GPSSpeed"], let speedValue = toDouble(speed) { gpsDict[kCGImagePropertyGPSSpeed as String] = speedValue } if let speedRef = additionalExif["GPSSpeedRef"] as? String { gpsDict[kCGImagePropertyGPSSpeedRef as String] = speedRef } if let imgDirection = additionalExif["GPSImgDirection"], let dirValue = toDouble(imgDirection) { gpsDict[kCGImagePropertyGPSImgDirection as String] = dirValue } if let imgDirectionRef = additionalExif["GPSImgDirectionRef"] as? String { gpsDict[kCGImagePropertyGPSImgDirectionRef as String] = imgDirectionRef } if let destBearing = additionalExif["GPSDestBearing"], let bearingValue = toDouble(destBearing) { gpsDict[kCGImagePropertyGPSDestBearing as String] = bearingValue } if let destBearingRef = additionalExif["GPSDestBearingRef"] as? String { gpsDict[kCGImagePropertyGPSDestBearingRef as String] = destBearingRef } if let dateStamp = additionalExif["GPSDateStamp"] as? String { gpsDict[kCGImagePropertyGPSDateStamp as String] = dateStamp } if let timeStamp = additionalExif["GPSTimeStamp"] as? String { gpsDict[kCGImagePropertyGPSTimeStamp as String] = timeStamp } if let hPositioningError = additionalExif["GPSHPositioningError"], let errorValue = toDouble(hPositioningError) { gpsDict[kCGImagePropertyGPSHPositioningError as String] = errorValue } return gpsDict } private func toDouble(_ value: Any) -> Double? { if let doubleValue = value as? Double { return doubleValue } else if let intValue = value as? Int { return Double(intValue) } else if let floatValue = value as? Float { return Double(floatValue) } return nil } }