chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+543
View File
@@ -0,0 +1,543 @@
// 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)
}
}
}