Files
projet_gestion_commande/frontend-admin/node_modules/expo-image-picker/ios/MediaHandler.swift
T
2026-02-07 22:33:02 +01:00

544 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<URL, Error>)
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)
}
}
}