// Copyright 2022-present 650 Industries. All rights reserved. import ExpoModulesCore import MobileCoreServices import Photos import PhotosUI import UniformTypeIdentifiers internal struct MediaHandler { internal weak var fileSystem: EXFileSystemInterface? internal let options: ImagePickerOptions internal func handleMedia(_ mediaInfo: MediaInfo) async throws -> AssetInfo { let mediaType: String? = mediaInfo[UIImagePickerController.InfoKey.mediaType] as? String switch mediaType { case UTType.image.identifier: return try await handleImage(mediaInfo: mediaInfo) case UTType.movie.identifier: return try await handleVideo(mediaInfo: mediaInfo) default: throw InvalidMediaTypeException(mediaType) } } internal func handleMultipleMedia(_ selection: [PHPickerResult]) async throws -> [AssetInfo] { // TODO: (@hirbod): Use withThrowingTaskGroup instead of asyncMap once iOS 15 support is dropped return try await asyncMap(selection) { selectedItem in let itemProvider = selectedItem.itemProvider if itemProvider.canLoadObject(ofClass: PHLivePhoto.self) && options.mediaTypes.contains(.livePhotos) { return try await handleLivePhoto(from: selectedItem) } if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { return try await handleImage(from: selectedItem) } if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { return try await handleVideo(from: selectedItem) } // Fallback to capability-based detection when the provider reports no identifiers (iOS bug?). // This can happen when files have been synced via AirDrop or iTunes and the file extension is not recognized. if itemProvider.canLoadObject(ofClass: UIImage.self) { return try await handleImage(from: selectedItem) } // Default fallback – assume video when image cannot be loaded. return try await handleVideo(from: selectedItem) } } // MARK: - Image private func handleImage(mediaInfo: MediaInfo) async throws -> AssetInfo { do { guard let image = ImageUtils.readImageFrom( mediaInfo: mediaInfo, shouldReadCroppedImage: options.allowsEditing) else { throw FailedToReadImageException() } let (imageData, fileExtension) = try ImageUtils.readDataAndFileExtension( image: image, mediaInfo: mediaInfo, options: options ) let targetUrl = try generateUrl(withFileExtension: fileExtension) let mimeType = getMimeType(from: targetUrl.pathExtension) // no modification requested let imageModified = options.allowsEditing || options.quality < 1 let fileWasCopied = !imageModified && ImageUtils.tryCopyingOriginalImageFrom(mediaInfo: mediaInfo, to: targetUrl) if !fileWasCopied { try ImageUtils.write(imageData: imageData, to: targetUrl) } // as calling this already requires media library permission, we can access it here // if user gave limited permissions, in the worst case this will be null let asset = mediaInfo[.phAsset] as? PHAsset var fileName = asset?.value(forKey: "filename") as? String // Extension will change to png when editing BMP files, reflect that change in fileName if let unwrappedName = fileName { fileName = replaceFileExtension( fileName: unwrappedName, targetExtension: fileExtension.lowercased()) } let fileSize = getFileSize(from: targetUrl) let base64 = options.base64 ? try ImageUtils.readBase64From( imageData: imageData, orImageFileUrl: targetUrl, tryReadingFile: fileWasCopied) : nil let exif = options.exif ? await ImageUtils.readExifFrom(mediaInfo: mediaInfo) : nil let size = CGSize(width: image.size.width, height: image.size.height) return AssetInfo( assetId: asset?.localIdentifier, uri: targetUrl.absoluteString, width: Double(size.width), height: Double(size.height), fileName: fileName, fileSize: fileSize, mimeType: mimeType, base64: base64, exif: exif ) } catch let exception as Exception { throw exception } catch { throw UnexpectedException(error) } } private func handleImage(from selectedImage: PHPickerResult) async throws -> AssetInfo { let itemProvider = selectedImage.itemProvider // Fast-path: copy original file when no processing is required and current representation is requested. let fastPath = !options.allowsEditing && options.quality >= 1 && options.preferredAssetRepresentationMode == .current if fastPath { // Attempt to obtain original file URL if let targetUrl = try? await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in guard let srcUrl = url else { return continuation.resume(throwing: error ?? FailedToReadImageException()) } do { let destUrl = try generateUrl(withFileExtension: "." + srcUrl.pathExtension) try FileManager.default.copyItem(at: srcUrl, to: destUrl) continuation.resume(returning: destUrl) } catch { continuation.resume(throwing: error) } } }) { let cachedUrl = targetUrl let fileExtension = "." + cachedUrl.pathExtension let size = ImageUtils.readVisualSizeFrom(url: cachedUrl) ?? .zero let fileSize = getFileSize(from: cachedUrl) let mimeType = getMimeType(from: cachedUrl.pathExtension) let fileName = itemProvider.suggestedName.map { $0 + fileExtension } // Conditionally read raw data only if needed to avoid unnecessary I/O var rawData: Data? if options.base64 || options.exif { rawData = try? Data(contentsOf: cachedUrl) } let base64 = options.base64 ? rawData?.base64EncodedString() : nil let exif = options.exif ? (rawData.flatMap { ImageUtils.readExifFrom(data: $0) }) : nil return AssetInfo( assetId: selectedImage.assetIdentifier, uri: cachedUrl.absoluteString, width: Double(size.width), height: Double(size.height), fileName: fileName, fileSize: fileSize, mimeType: mimeType, base64: base64, exif: exif ) } } // If fast copy path failed or was not available because of the props // use slow path (existing implementation) let rawData = try await itemProvider.loadImageDataRepresentation() guard let image = UIImage(data: rawData) else { throw Exception(name: "FailedCreatingUIImage", description: "") } let (imageData, fileExtension) = try ImageUtils.readDataAndFileExtension( image: image, rawData: rawData, itemProvider: itemProvider, options: options ) let mimeType = getMimeType(from: String(fileExtension.dropFirst())) let targetUrl = try generateUrl(withFileExtension: fileExtension) try ImageUtils.write(imageData: imageData, to: targetUrl) let fileSize = getFileSize(from: targetUrl) let fileName = itemProvider.suggestedName.map { $0 + fileExtension } // We need to get EXIF from original image data, as it is being lost in UIImage let exif = options.exif ? ImageUtils.readExifFrom(data: rawData) : nil let base64 = options.base64 ? imageData?.base64EncodedString() : nil let size = CGSize(width: image.size.width, height: image.size.height) return AssetInfo( assetId: selectedImage.assetIdentifier, uri: targetUrl.absoluteString, width: Double(size.width), height: Double(size.height), fileName: fileName, fileSize: fileSize, mimeType: mimeType, base64: base64, exif: exif ) } // Unlike the case of regular images, we have to operate on original data of the image in order to preserve the exif data, // otherwise it won't be possible to connect the image and video into a `PHLivePhoto` after reading it from the cache directory later. // As a result a live photo photo cannot be compressed or edited. private func handleLivePhoto(from selectedImage: PHPickerResult) async throws -> AssetInfo { let itemProvider = selectedImage.itemProvider let livePhotoObject = try await itemProvider.loadObject(ofClass: PHLivePhoto.self) guard let livePhoto = livePhotoObject as? PHLivePhoto else { throw FailedToPickLivePhotoException() } let assetResources = PHAssetResource.assetResources(for: livePhoto) guard let photoResource = assetResources.first(where: { $0.type == .photo }), let videoResource = assetResources.first(where: { $0.type == .pairedVideo }) else { throw FailedToPickLivePhotoException() } let fileName = photoResource.originalFilename let pairedVideoFileName = videoResource.originalFilename let photoFileExtension = getFileExtension(from: fileName) let pairedVideoFileExtension = getFileExtension(from: pairedVideoFileName) let (photoUrl, pairedVideoUrl) = try generatePairedUrls( photoFileExtension: photoFileExtension, videoFileExtension: pairedVideoFileExtension) let imageData = try await PHAssetResourceManager.default().requestData( for: photoResource, options: nil) try await PHAssetResourceManager.default().writeData( for: photoResource, toFile: photoUrl, options: nil) try await PHAssetResourceManager.default().writeData( for: videoResource, toFile: pairedVideoUrl, options: nil) let fileSize = getFileSize(from: photoUrl) let mimeType = getMimeType(from: photoUrl.pathExtension) let base64 = options.base64 ? imageData.base64EncodedString() : nil let exif = options.exif ? ImageUtils.readExifFrom(data: imageData) : nil let pairedVideoAssetInfo = try getPairedAssetInfo( from: videoResource, fileUrl: pairedVideoUrl, assetId: selectedImage.assetIdentifier) return AssetInfo( assetId: selectedImage.assetIdentifier, type: "livePhoto", uri: photoUrl.absoluteString, width: livePhoto.size.width, height: livePhoto.size.height, fileName: fileName, fileSize: fileSize, mimeType: mimeType, base64: base64, exif: exif, pairedVideoAsset: pairedVideoAssetInfo ) } private func getPairedAssetInfo( from videoResource: PHAssetResource, fileUrl: URL, assetId: String? ) throws -> AssetInfo { let fileName = videoResource.originalFilename guard let dimensions = VideoUtils.readSizeFrom(url: fileUrl) else { throw FailedToReadVideoSizeException() } let duration = VideoUtils.readDurationFrom(url: fileUrl) let mimeType = getMimeType(from: fileUrl.pathExtension) let fileSize = getFileSize(from: fileUrl) return AssetInfo( assetId: assetId, type: "pairedVideo", uri: fileUrl.absoluteString, width: dimensions.width, height: dimensions.height, fileName: fileName, fileSize: fileSize, mimeType: mimeType, duration: duration ) } private func getMimeType(from pathExtension: String) -> String? { return UTType(filenameExtension: pathExtension)?.preferredMIMEType } // MARK: - Video func handleVideo(mediaInfo: MediaInfo) async throws -> AssetInfo { // Attempt to obtain a usable URL first. let pickedVideoUrl = VideoUtils.readVideoUrlFrom(mediaInfo: mediaInfo) // Fast-path: if we have a PHAsset and passthrough preset, stream the full-size resource to avoid // assets-library URLs that FileManager cannot copy. if options.videoExportPreset == .passthrough, let asset = mediaInfo[.phAsset] as? PHAsset { let resources = PHAssetResource.assetResources(for: asset) if let resource = resources.first(where: { $0.type == .fullSizeVideo }) ?? resources.first(where: { $0.type == .video }) { let originalFilename = resource.originalFilename let fileExtension = getFileExtension(from: originalFilename) let destinationUrl = try generateUrl(withFileExtension: fileExtension) try await PHAssetResourceManager.default().writeData( for: resource, toFile: destinationUrl, options: nil ) let mimeType = getMimeType(from: destinationUrl.pathExtension) return try buildVideoResult( for: destinationUrl, withName: originalFilename, mimeType: mimeType, assetId: asset.localIdentifier ) } } // Legacy/regular path: copy the temporary file when we have a real URL. guard let pickedUrl = pickedVideoUrl else { throw FailedToReadVideoException() } // If the URL uses the deprecated assets-library scheme, fall back to exporting via PHAsset above. // This can happen when files have been synced via AirDrop or iTunes. if pickedUrl.scheme == "assets-library" { throw FailedToReadVideoException() } let targetUrl = try generateUrl(withFileExtension: ".mov") try VideoUtils.tryCopyingVideo(at: pickedUrl, to: targetUrl) guard let dimensions = VideoUtils.readSizeFrom(url: targetUrl) else { throw FailedToReadVideoSizeException() } // If video was edited (the duration is affected) then read the duration from the original edited video. // Otherwise read the duration from the target video file. // TODO: (@bbarthec): inspect whether it makes sense to read duration from two different assets let videoUrlToReadDurationFrom = self.options.allowsEditing ? pickedUrl : targetUrl let asset = mediaInfo[.phAsset] as? PHAsset let mimeType = getMimeType(from: targetUrl.pathExtension) let fileName = asset?.value(forKey: "filename") as? String let fileSize = getFileSize(from: targetUrl) return AssetInfo( assetId: asset?.localIdentifier, type: "video", uri: targetUrl.absoluteString, width: dimensions.width, height: dimensions.height, fileName: fileName, fileSize: fileSize, mimeType: mimeType, duration: VideoUtils.readDurationFrom(url: videoUrlToReadDurationFrom) ) } private func handleVideo(from selectedVideo: PHPickerResult) async throws -> AssetInfo { // Fast-path: If transcoding is disabled (passthrough) and we have direct access to the underlying // `PHAsset`, try to copy the original/full-size video resource via `PHAssetResourceManager`. // This avoids `loadFileRepresentation`, which can be noticeably slower once a user // tweaks only the metadata (e.g. adjusts the capture date) because the photo service marks the // asset as *adjusted* and will re-render a temporary file for us. Copying the resource bytes // ourselves is dramatically faster because it just streams the already-existing file. if options.videoExportPreset == .passthrough, let assetId = selectedVideo.assetIdentifier { let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) if let asset = fetchResult.firstObject { // Prefer the full-size resource when available, otherwise fall back to the default `.video`. let resources = PHAssetResource.assetResources(for: asset) if let resource = resources.first(where: { $0.type == .fullSizeVideo }) ?? resources.first(where: { $0.type == .video }) { // Determine the file extension from the original filename so we preserve it (e.g. .MOV / .MP4) let originalFilename = resource.originalFilename let fileExtension = getFileExtension(from: originalFilename) let destinationUrl = try generateUrl(withFileExtension: fileExtension) // Stream the resource into our cache directory. This API is asynchronous but doesn't require // a temporary file like `loadFileRepresentation`. try await PHAssetResourceManager.default().writeData(for: resource, toFile: destinationUrl, options: nil) // Build and return the result using the helper. let mimeType = getMimeType(from: destinationUrl.pathExtension) return try buildVideoResult( for: destinationUrl, withName: originalFilename, mimeType: mimeType, assetId: assetId ) } } // If anything above fails we'll gracefully fall back to the existing (slower) path below. } let videoUrl = try await VideoUtils.loadVideoRepresentation( provider: selectedVideo.itemProvider ) { tmpUrl in // We need to copy the result into a place that we control, because the picker // can remove the original file during conversion. return try generateUrl(withFileExtension: ".\(tmpUrl.pathExtension)") } // Decide whether we need to transcode. let isPassthrough = options.videoExportPreset == .passthrough // Keep original extension for passthrough, otherwise use mp4. let originalFileExtension = ".\(videoUrl.pathExtension)" let transcodeFileExtension = isPassthrough ? originalFileExtension : ".mp4" let transcodeFileType: AVFileType = .mp4 let finalUrl: URL if isPassthrough { finalUrl = videoUrl } else { // Create destination url for transcoded video let transcodedUrl = try generateUrl(withFileExtension: transcodeFileExtension) finalUrl = try await VideoUtils.transcodeVideoAsync( sourceAssetUrl: videoUrl, destinationUrl: transcodedUrl, outputFileType: transcodeFileType, exportPreset: options.videoExportPreset ) } let mimeType = getMimeType(from: finalUrl.pathExtension) let fileName = selectedVideo.itemProvider.suggestedName.map { $0 + transcodeFileExtension } return try buildVideoResult( for: finalUrl, withName: fileName, mimeType: mimeType, assetId: selectedVideo.assetIdentifier) } // MARK: - utils private func replaceFileExtension(fileName: String, targetExtension: String) -> String { if !fileName.lowercased().hasSuffix(targetExtension.lowercased()) { return deleteFileExtension(fileName: fileName) + targetExtension } return fileName } private func deleteFileExtension(fileName: String) -> String { var components = fileName.components(separatedBy: ".") guard components.count > 1 else { return fileName } components.removeLast() return components.joined(separator: ".") } private func generateUrl(withFileExtension: String) throws -> URL { guard let fileSystem = self.fileSystem else { throw FileSystemModuleNotFoundException() } let directory = fileSystem.cachesDirectory.appending( fileSystem.cachesDirectory.hasSuffix("/") ? "" : "/" + "ImagePicker" ) let path = fileSystem.generatePath(inDirectory: directory, withExtension: withFileExtension) return URL(fileURLWithPath: path) } private func generatePairedUrls(photoFileExtension: String, videoFileExtension: String) throws -> (URL, URL) { let parsedVideoFileExtension = videoFileExtension.starts(with: ".") ? String(videoFileExtension.dropFirst()) : videoFileExtension let photoUrl = try generateUrl(withFileExtension: photoFileExtension) let baseUrl = photoUrl.deletingLastPathComponent() let filename = photoUrl.deletingPathExtension().lastPathComponent let videoUrl = baseUrl.appendingPathComponent(filename).appendingPathExtension( parsedVideoFileExtension) return (photoUrl, videoUrl) } private func buildVideoResult( for videoUrl: URL, withName fileName: String?, mimeType: String?, assetId: String? ) throws -> AssetInfo { guard let size = VideoUtils.readSizeFrom(url: videoUrl) else { throw FailedToReadVideoSizeException() } let duration = VideoUtils.readDurationFrom(url: videoUrl) let fileSize = getFileSize(from: videoUrl) return AssetInfo( assetId: assetId, type: "video", uri: videoUrl.absoluteString, width: size.width, height: size.height, fileName: fileName, fileSize: fileSize, mimeType: mimeType, duration: duration ) } private func getFileSize(from fileUrl: URL) -> Int? { do { let resources = try fileUrl.resourceValues(forKeys: [.fileSizeKey]) return resources.fileSize } catch { log.error("Failed to get file size for \(fileUrl.absoluteString)") return nil } } private func getFileExtension(from fileName: String) -> String { return ".\(URL(fileURLWithPath: fileName).pathExtension)" } } extension PHAssetResourceManager { fileprivate func requestData( for assetResource: PHAssetResource, options: PHAssetResourceRequestOptions? ) async throws -> Data { return try await withCheckedThrowingContinuation { continuation in var data = Data() let dataHandler = { (dataBatch: Data) in data.append(dataBatch) } let completionHandler = { (error: Error?) in if let error { continuation.resume(throwing: error) return } continuation.resume(returning: data) } self.requestData(for: assetResource, options: options, dataReceivedHandler: dataHandler, completionHandler: completionHandler) } } }