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
@@ -0,0 +1,34 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoImagePicker'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1'
}
s.swift_version = '5.9'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift}"
end
end
@@ -0,0 +1,141 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal final class PermissionsModuleNotFoundException: Exception {
override var reason: String {
"Permissions module not found. Are you sure that Expo modules are properly linked?"
}
}
internal final class FileSystemModuleNotFoundException: Exception {
override var reason: String {
"FileSystem module not found. Are you sure that Expo modules are properly linked?"
}
}
internal final class LoggerModuleNotFoundException: Exception {
override var reason: String {
"Logger module not found. Are you sure that Expo modules are properly linked?"
}
}
internal final class MissingCameraPermissionException: Exception {
override var reason: String {
"Missing camera or camera roll permission"
}
}
internal final class MissingMicrophonePermissionException: Exception {
override var reason: String {
"Missing microphone permission. Please enable it with the `expo-image-picker` config plugin"
}
}
internal final class MissingPhotoLibraryPermissionException: Exception {
override var reason: String {
"Missing photo library permission"
}
}
internal final class CameraUnavailableOnSimulatorException: Exception {
override var reason: String {
"Camera not available on simulator"
}
}
internal final class MultiselectUnavailableException: Exception {
override var reason: String {
"Multiple selection is only available on iOS 14+"
}
}
internal final class MissingCurrentViewControllerException: Exception {
override var reason: String {
"Cannot determine currently presented view controller"
}
}
internal final class MaxDurationWhileEditingExceededException: Exception {
override var reason: String {
"'videoMaxDuration' limits to 600 when 'allowsEditing=true'"
}
}
internal final class InvalidMediaTypeException: GenericException<String?> {
override var reason: String {
"Cannot handle '\(param ?? "nil")' media type"
}
}
internal final class FailedToCreateGifException: Exception {
override var reason: String {
"Failed to create image destination for GIF export"
}
}
internal final class FailedToExportGifException: Exception {
override var reason: String {
"Failed to export requested GIF"
}
}
internal final class FailedToWriteImageException: Exception {
override var reason: String {
"Failed to write data to a file"
}
}
internal final class FailedToReadImageException: Exception {
override var reason: String {
"Failed to read picked image"
}
}
internal final class FailedToReadImageDataException: Exception {
override var reason: String {
"Failed to read data from a file"
}
}
internal final class FailedToReadVideoSizeException: Exception {
override var reason: String {
"Failed to read the video size"
}
}
internal final class FailedToReadVideoException: Exception {
override var reason: String {
"Failed to read picked video"
}
}
internal final class FailedToTranscodeVideoException: Exception {
override var reason: String {
"Failed to transcode picked video"
}
}
internal final class UnsupportedVideoExportPresetException: GenericException<String> {
override var reason: String {
"Video cannot be transcoded with export preset: \(param)"
}
}
internal final class FailedToPickVideoException: Exception {
override var reason: String {
"Video could not be picked"
}
}
internal final class FailedToReadImageDataForBase64Exception: Exception {
override var reason: String {
"Failed to read image data to perform base64 encoding"
}
}
internal final class FailedToPickLivePhotoException: Exception {
override var reason: String {
"Failed to read the selected item as a live photo"
}
}
@@ -0,0 +1,108 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import PhotosUI
/**
Protocol that describes scenarios we care about while the user is picking media.
*/
protocol OnMediaPickingResultHandler {
func didPickMultipleMedia(selection: [PHPickerResult])
func didPickMedia(mediaInfo: MediaInfo)
func didCancelPicking()
}
/**
This class is responsible for responding to any events that are happening in `UIImagePickerController`.
It then forwards them back in unified way via `OnMediaPickingResultHandler`.
The functionality of this delegate is separated from the main module class for two reasons:
1) main module cannot inherit from `NSObject` (and that's required by three protocols we must conform to),
because it already inherits from `Module` class and Swift language does not allow multiple inheritance,
2) it separates some logic from the main module class and hopefully makes it cleaner.
*/
internal class ImagePickerHandler: NSObject,
PHPickerViewControllerDelegate,
UINavigationControllerDelegate,
UIImagePickerControllerDelegate,
UIAdaptivePresentationControllerDelegate {
private let onMediaPickingResultHandler: OnMediaPickingResultHandler
private let hideStatusBarWhenPresented: Bool
private var statusBarVisibilityController = StatusBarVisibilityController()
init(onMediaPickingResultHandler: OnMediaPickingResultHandler, hideStatusBarWhenPresented: Bool) {
self.onMediaPickingResultHandler = onMediaPickingResultHandler
self.hideStatusBarWhenPresented = hideStatusBarWhenPresented
}
private func handlePickedMedia(mediaInfo: MediaInfo) {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didPickMedia(mediaInfo: mediaInfo)
}
private func handlePickedMedia(selection: [PHPickerResult]) {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didPickMultipleMedia(selection: selection)
}
private func handlePickingCancellation() {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didCancelPicking()
}
// MARK: - UIImagePickerControllerDelegate
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: MediaInfo) {
handlePickedMedia(mediaInfo: info)
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
handlePickingCancellation()
picker.dismiss(animated: true)
}
// MARK: - PHPickerViewControllerDelegate
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// The PHPickerViewController returns empty collection when canceled
if results.isEmpty {
handlePickingCancellation()
} else {
handlePickedMedia(selection: results)
}
picker.dismiss(animated: true)
}
// MARK: - UIAdaptivePresentationControllerDelegate
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
handlePickingCancellation()
}
// MARK: - UINavigationControllerDelegate
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
statusBarVisibilityController.maybePreserveVisibilityAndHideStatusBar(hideStatusBarWhenPresented)
}
}
/**
Protocol that is a common type for supported picker controllers.
*/
internal protocol PickerUIController: UIViewController {
func setResultHandler(_ handler: ImagePickerHandler)
}
extension UIImagePickerController: PickerUIController {
func setResultHandler(_ handler: ImagePickerHandler) {
self.delegate = handler
self.presentationController?.delegate = handler
}
}
extension PHPickerViewController: PickerUIController {
func setResultHandler(_ handler: ImagePickerHandler) {
self.delegate = handler
self.presentationController?.delegate = handler
}
}
@@ -0,0 +1,251 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import UIKit
import PhotosUI
import ExpoModulesCore
typealias MediaInfo = [UIImagePickerController.InfoKey: Any]
/**
Helper struct storing single picking operation context variables that have their own non-sharable state.
*/
struct PickingContext {
let promise: Promise
let options: ImagePickerOptions
let imagePickerHandler: ImagePickerHandler
}
enum OperationType {
case ask
case get
}
public class ImagePickerModule: Module, OnMediaPickingResultHandler {
public func definition() -> ModuleDefinition {
// TODO: (@bbarthec) change to "ExpoImagePicker" and propagate to other platforms
Name("ExponentImagePicker")
OnCreate {
self.appContext?.permissions?.register([
CameraPermissionRequester(),
MediaLibraryPermissionRequester(),
MediaLibraryWriteOnlyPermissionRequester()
])
}
AsyncFunction("getCameraPermissionsAsync", { (promise: Promise) in
self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .get, promise: promise)
})
AsyncFunction("getMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .get, promise: promise)
})
AsyncFunction("requestCameraPermissionsAsync", { (promise: Promise) in
self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .ask, promise: promise)
})
AsyncFunction("requestMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .ask, promise: promise)
})
AsyncFunction("launchCameraAsync", { (options: ImagePickerOptions, promise: Promise) in
guard let permissions = self.appContext?.permissions else {
return promise.reject(PermissionsModuleNotFoundException())
}
guard permissions.hasGrantedPermission(usingRequesterClass: CameraPermissionRequester.self) else {
return promise.reject(MissingCameraPermissionException())
}
self.launchImagePicker(sourceType: .camera, options: options, promise: promise)
})
.runOnQueue(DispatchQueue.main)
AsyncFunction("launchImageLibraryAsync", { (options: ImagePickerOptions, promise: Promise) in
self.launchImagePicker(sourceType: .photoLibrary, options: options, promise: promise)
})
.runOnQueue(DispatchQueue.main)
}
private var currentPickingContext: PickingContext?
private func handlePermissionRequest(requesterClass: AnyClass, operationType: OperationType, promise: Promise) {
guard let permissions = self.appContext?.permissions else {
return promise.reject(PermissionsModuleNotFoundException())
}
switch operationType {
case .get: permissions.getPermissionUsingRequesterClass(requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
case .ask: permissions.askForPermission(usingRequesterClass: requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
}
}
private func getMediaLibraryPermissionRequester(_ writeOnly: Bool) -> AnyClass {
return writeOnly ? MediaLibraryWriteOnlyPermissionRequester.self : MediaLibraryPermissionRequester.self
}
private func launchImagePicker(sourceType: UIImagePickerController.SourceType, options: ImagePickerOptions, promise: Promise) {
let imagePickerDelegate = ImagePickerHandler(onMediaPickingResultHandler: self, hideStatusBarWhenPresented: options.allowsEditing && !options.allowsMultipleSelection)
let pickingContext = PickingContext(promise: promise,
options: options,
imagePickerHandler: imagePickerDelegate)
if !options.allowsEditing && sourceType != .camera {
self.launchMultiSelectPicker(pickingContext: pickingContext)
} else {
self.launchLegacyImagePicker(sourceType: sourceType, pickingContext: pickingContext)
}
}
private func launchLegacyImagePicker(sourceType: UIImagePickerController.SourceType, pickingContext: PickingContext) {
let options = pickingContext.options
let picker = UIImagePickerController()
picker.fixCannotMoveEditingBox()
if sourceType == .camera {
#if targetEnvironment(simulator)
return pickingContext.promise.reject(CameraUnavailableOnSimulatorException())
#else
picker.sourceType = .camera
picker.cameraDevice = options.cameraType == .front ? .front : .rear
#endif
}
if sourceType == .photoLibrary {
picker.sourceType = .photoLibrary
}
picker.mediaTypes = options.toMediaTypesArray()
if options.requiresMicrophonePermission() && sourceType == .camera {
do {
try checkMicrophonePermissions()
} catch {
pickingContext.promise.reject(error)
return
}
}
picker.videoExportPreset = options.videoExportPreset.toAVAssetExportPreset()
picker.videoQuality = options.videoQuality.toQualityType()
picker.videoMaximumDuration = options.videoMaxDuration
if options.allowsEditing {
picker.allowsEditing = options.allowsEditing
if options.videoMaxDuration > 600 {
return pickingContext.promise.reject(MaxDurationWhileEditingExceededException())
}
if options.videoMaxDuration == 0 {
picker.videoMaximumDuration = 600.0
}
}
presentPickerUI(picker, pickingContext: pickingContext)
}
private func checkMicrophonePermissions() throws {
guard Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") != nil else {
throw MissingMicrophonePermissionException()
}
}
private func launchMultiSelectPicker(pickingContext: PickingContext) {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
let options = pickingContext.options
// selection limit = 1 --> single selection, reflects the old picker behavior
configuration.selectionLimit = options.allowsMultipleSelection ? options.selectionLimit : SINGLE_SELECTION
configuration.filter = options.toPickerFilter()
configuration.preferredAssetRepresentationMode = options.preferredAssetRepresentationMode.toAssetRepresentationMode()
configuration.selection = options.orderedSelection ? .ordered : .default
let picker = PHPickerViewController(configuration: configuration)
presentPickerUI(picker, pickingContext: pickingContext)
}
private func presentPickerUI(_ picker: PickerUIController, pickingContext context: PickingContext) {
guard let currentViewController = self.appContext?.utilities?.currentViewController() else {
return context.promise.reject(MissingCurrentViewControllerException())
}
picker.modalPresentationStyle = context.options.presentationStyle.toPresentationStyle()
if UIDevice.current.userInterfaceIdiom == .pad {
let viewFrame = currentViewController.view.frame
picker.popoverPresentationController?.sourceRect = CGRect(
x: viewFrame.midX,
y: viewFrame.maxY,
width: 0,
height: 0
)
picker.popoverPresentationController?.sourceView = currentViewController.view
}
picker.setResultHandler(context.imagePickerHandler)
// Store picking context as we're navigating to the different view controller (starting asynchronous flow)
self.currentPickingContext = context
currentViewController.present(picker, animated: true, completion: nil)
}
// MARK: - OnMediaPickingResultHandler
func didCancelPicking() {
self.currentPickingContext?.promise.resolve(ImagePickerResponse(assets: nil, canceled: true))
self.currentPickingContext = nil
}
func didPickMultipleMedia(selection: [PHPickerResult]) {
guard let options = self.currentPickingContext?.options,
let promise = self.currentPickingContext?.promise else {
log.error("Picking operation context has been lost.")
return
}
guard let fileSystem = self.appContext?.fileSystem else {
return promise.reject(FileSystemModuleNotFoundException())
}
let mediaHandler = MediaHandler(fileSystem: fileSystem,
options: options)
// Clean up the currently stored picking context
self.currentPickingContext = nil
Task {
do {
let assets = try await mediaHandler.handleMultipleMedia(selection)
promise.resolve(ImagePickerResponse(assets: assets, canceled: false))
} catch {
promise.reject(error)
}
}
}
func didPickMedia(mediaInfo: MediaInfo) {
guard let options = self.currentPickingContext?.options,
let promise = self.currentPickingContext?.promise else {
log.error("Picking operation context has been lost.")
return
}
guard let fileSystem = self.appContext?.fileSystem else {
return promise.reject(FileSystemModuleNotFoundException())
}
// Clean up the currently stored picking context
self.currentPickingContext = nil
let mediaHandler = MediaHandler(fileSystem: fileSystem,
options: options)
Task {
do {
let asset = try await mediaHandler.handleMedia(mediaInfo)
promise.resolve(ImagePickerResponse(assets: [asset], canceled: false))
} catch {
promise.reject(error)
}
}
}
}
@@ -0,0 +1,256 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
import MobileCoreServices
import PhotosUI
internal let MAXIMUM_QUALITY = 1.0
internal let UNLIMITED_SELECTION = 0
internal let SINGLE_SELECTION = 1
internal struct ImagePickerOptions: Record {
@Field
var allowsEditing: Bool = false
@Field
var aspect: [Double]
@Field
var quality: Double = 1.0
@Field
var mediaTypes: [MediaType] = [.images]
@Field
var exif: Bool
@Field
var base64: Bool = false
@Field
var videoExportPreset: VideoExportPreset = .passthrough
@Field
var videoQuality: VideoQuality = .typeHigh
@Field
var videoMaxDuration: Double = 0
@Field
var presentationStyle: PresentationStyle = .automatic
@Field
var preferredAssetRepresentationMode: PreferredAssetRepresentationMode = .current
@Field
var cameraType: CameraType = .back
@Field
var allowsMultipleSelection: Bool = false
@Field
var selectionLimit: Int = UNLIMITED_SELECTION
@Field
var orderedSelection: Bool = false
func toMediaTypesArray() -> [String] {
var mediaTypesArray = mediaTypes.map { mediaType in
mediaType.toUTTypeString()
}
// For legacy picker selecting only livePhotos is not allowed
if mediaTypes.contains(.livePhotos) && !mediaTypes.contains(.images) {
mediaTypesArray.append(UTType.image.identifier)
}
if mediaTypesArray.isEmpty {
return [UTType.image.identifier]
}
return mediaTypesArray
}
func toPickerFilter() -> PHPickerFilter {
let allowedArray = mediaTypes.map { mediaType in
mediaType.toPickerFilter()
}
if allowedArray.isEmpty {
return .images
}
return .any(of: allowedArray)
}
func requiresMicrophonePermission() -> Bool {
return mediaTypes.contains { mediaType in
mediaType.requiresMicrophonePermission()
}
}
}
internal enum PresentationStyle: String, Enumerable {
case fullScreen
case pageSheet
case formSheet
case currentContext
case overFullScreen
case overCurrentContext
case popover
case none
case automatic
func toPresentationStyle() -> UIModalPresentationStyle {
switch self {
case .fullScreen:
return .fullScreen
case .pageSheet:
return .pageSheet
case .formSheet:
return .formSheet
case .currentContext:
return .currentContext
case .overFullScreen:
return .overFullScreen
case .overCurrentContext:
return .overCurrentContext
case .popover:
return .popover
case .none:
return .none
case .automatic:
if #available(iOS 13.0, *) {
return .automatic
}
// default prior iOS 13
return .fullScreen
}
}
}
internal enum PreferredAssetRepresentationMode: String, Enumerable {
case automatic
case compatible
case current
func toAssetRepresentationMode() -> PHPickerConfiguration.AssetRepresentationMode {
switch self {
case .automatic:
return .automatic
case .compatible:
return .compatible
case .current:
return .current
}
}
}
internal enum VideoQuality: Int, Enumerable {
case typeHigh = 0
case typeMedium = 1
case typeLow = 2
case type640x480 = 3
case typeIFrame1280x720 = 4
case typeIFrame960x540 = 5
func toQualityType() -> UIImagePickerController.QualityType {
switch self {
case .typeHigh:
return .typeHigh
case .typeMedium:
return .typeMedium
case .typeLow:
return .typeLow
case .type640x480:
return .type640x480
case .typeIFrame1280x720:
return .typeIFrame1280x720
case .typeIFrame960x540:
return .typeIFrame960x540
}
}
}
internal enum MediaType: String, Enumerable {
case videos
case images
case livePhotos
func toUTTypeString() -> String {
switch self {
case .images:
return UTType.image.identifier
case .videos:
return UTType.movie.identifier
case .livePhotos:
return UTType.livePhoto.identifier
}
}
func requiresMicrophonePermission() -> Bool {
switch self {
case .images:
return false
case .videos:
return true
case .livePhotos:
return false
}
}
func toPickerFilter() -> PHPickerFilter {
switch self {
case .images:
return .images
case .videos:
return .videos
case .livePhotos:
return .livePhotos
}
}
}
internal enum VideoExportPreset: Int, Enumerable {
case passthrough = 0
case lowQuality = 1
case mediumQuality = 2
case highestQuality = 3
case h264_640x480 = 4
case h264_960x540 = 5
case h264_1280x720 = 6
case h264_1920x1080 = 7
case h264_3840x2160 = 8
case hevc_1920x1080 = 9
case hevc_3840_2160 = 10
func toAVAssetExportPreset() -> String {
switch self {
case .passthrough:
return AVAssetExportPresetPassthrough
case .lowQuality:
return AVAssetExportPresetLowQuality
case .mediumQuality:
return AVAssetExportPresetMediumQuality
case .highestQuality:
return AVAssetExportPresetHighestQuality
case .h264_640x480:
return AVAssetExportPreset640x480
case .h264_960x540:
return AVAssetExportPreset960x540
case .h264_1280x720:
return AVAssetExportPreset1280x720
case .h264_1920x1080:
return AVAssetExportPreset1920x1080
case .h264_3840x2160:
return AVAssetExportPreset3840x2160
case .hevc_1920x1080:
return AVAssetExportPresetHEVC1920x1080
case .hevc_3840_2160:
return AVAssetExportPresetHEVC3840x2160
}
}
}
internal enum CameraType: String, Enumerable {
case back
case front
}
@@ -0,0 +1,123 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import Photos
import ExpoModulesCore
public class CameraPermissionRequester: NSObject, EXPermissionsRequester {
static public func permissionType() -> String {
return "camera"
}
public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
AVCaptureDevice.requestAccess(for: AVMediaType.video) { [weak self] _ in
resolve(self?.getPermissions())
}
}
public func getPermissions() -> [AnyHashable: Any] {
var systemStatus: AVAuthorizationStatus
var status: EXPermissionStatus
let cameraUsageDescription = Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription")
if cameraUsageDescription == nil {
EXFatal(EXErrorWithMessage("""
This app is missing 'NSCameraUsageDescription', video services will fail. \
Ensure this key exists in the app's Info.plist
"""))
systemStatus = AVAuthorizationStatus.denied
} else {
systemStatus = AVCaptureDevice.authorizationStatus(for: AVMediaType.video)
}
switch systemStatus {
case .authorized:
status = EXPermissionStatusGranted
case .restricted,
.denied:
status = EXPermissionStatusDenied
case .notDetermined:
fallthrough
@unknown default:
status = EXPermissionStatusUndetermined
}
return [
"status": status.rawValue
]
}
}
public class MediaLibraryPermissionRequester: DefaultMediaLibraryPermissionRequester,
EXPermissionsRequester {
public static func permissionType() -> String {
return "mediaLibrary"
}
}
public class MediaLibraryWriteOnlyPermissionRequester: DefaultMediaLibraryPermissionRequester,
EXPermissionsRequester {
public static func permissionType() -> String {
return "mediaLibraryWriteOnly"
}
override internal func accessLevel() -> PHAccessLevel {
return PHAccessLevel.addOnly
}
}
// MARK: - Permission requesters shared implementation extracted to an extension (mixin pattern)
/**
* Dummy class just to prevent extending NSObject publicly/globally.
*/
public class DefaultMediaLibraryPermissionRequester: NSObject {}
/**
* This extension is adding default implmentation for EXPermissionsRequester that can be shared by many classe.
* In Swift language you cannot override static methods in subclasses, so you cannot subclass any already implemented
* PermissionRequester as instances of this class are registered by the unique name coming from `static func permissionType()`.
* To prevent repeating the similar code for every MediaLibrary PermissionRequester (the only differences so far are
* aforementioned permissionType and accessLevel, while the latter can be easily overritten) I've extracted the code
* to this extension. I'm using as a mixin that implements major part of EXPermissionsRequester protocol.
*/
extension DefaultMediaLibraryPermissionRequester {
@objc
public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
PHPhotoLibrary.requestAuthorization(for: self.accessLevel()) { [weak self] (_: PHAuthorizationStatus) in
resolve(self?.getPermissions())
}
}
@objc
public func getPermissions() -> [AnyHashable: Any] {
let authorizationStatus = PHPhotoLibrary.authorizationStatus(for: self.accessLevel())
var status: EXPermissionStatus
var scope: String
switch authorizationStatus {
case .authorized:
status = EXPermissionStatusGranted
scope = "all"
case .limited:
status = EXPermissionStatusGranted
scope = "limited"
case .denied, .restricted:
status = EXPermissionStatusDenied
scope = "none"
case .notDetermined:
fallthrough
@unknown default:
status = EXPermissionStatusUndetermined
scope = "none"
}
return [
"status": status.rawValue,
"accessPrivileges": scope
]
}
@objc
internal func accessLevel() -> PHAccessLevel {
return PHAccessLevel.readWrite
}
}
@@ -0,0 +1,43 @@
// Copyright 2022-present 650 Industries. All rights reserved.
// swiftlint:disable redundant_optional_initialization
// Unfortunately, property wrappers must be initialized in those records, otherwise the memberwise initializer
// would require `Field<FieldType?>` as an argument instead of `FieldType?`.
// TODO: (@tsapeta) Figure out if we can fix that
import ExpoModulesCore
internal typealias ImagePickerResult = Result<ImagePickerResponse, Exception>
internal typealias SelectedMediaResult = Result<AssetInfo, Exception>
/**
Convenience alias, a dictionary representing EXIF data
*/
internal typealias ExifInfo = [String: Any]
/**
Represents a picker response.
*/
internal struct ImagePickerResponse: Record {
@Field var assets: [AssetInfo]? = nil
@Field var canceled: Bool = true
}
/**
Represents a single asset (image, live photo or video).
*/
internal struct AssetInfo: Record {
@Field var assetId: String? = nil
@Field var type: String = "image"
@Field var uri: String = ""
@Field var width: Double = 0
@Field var height: Double = 0
@Field var fileName: String? = nil
@Field var fileSize: Int? = nil
@Field var mimeType: String? = nil
@Field var base64: String? = nil
@Field var exif: ExifInfo? = nil
@Field var duration: Double? = nil
@Field var pairedVideoAsset: AssetInfo? = nil
}
@@ -0,0 +1,16 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Asynchronously maps the given sequence (sequentially).
*/
func asyncMap<ItemsType: Sequence, ResultType>(
_ items: ItemsType,
_ transform: (ItemsType.Element) async throws -> ResultType
) async rethrows -> [ResultType] {
var values = [ResultType]()
for item in items {
try await values.append(transform(item))
}
return values
}
+392
View File
@@ -0,0 +1,392 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
import ImageIO
import Photos
import UniformTypeIdentifiers
extension UTType {
static var avif: UTType {
UTType(importedAs: "public.avif")
}
}
internal struct ImageUtils {
static func readImageFrom(mediaInfo: MediaInfo, shouldReadCroppedImage: Bool) -> UIImage? {
// ---------------------------------------------------------------------------
// 1. Fast-path (allowsEditing == true)
// ---------------------------------------------------------------------------
// When the user confirms a crop in the system UI, UIKit puts the final bitmap
// with the crop *and* the correct visual orientation already baked in
// under `UIImagePickerController.InfoKey.editedImage`.
// Re-using that image means we don't have to:
// translate `cropRect` into the sensor's coordinate space, nor
// worry about EXIF orientation flags that differ across formats.
// This single line therefore handles the vast majority of cases safely.
if shouldReadCroppedImage, let systemCropped = mediaInfo[.editedImage] as? UIImage {
return systemCropped.fixOrientation()
}
// ---------------------------------------------------------------------------
// 2. Manual path (no .editedImage available)
// ---------------------------------------------------------------------------
// Some edge-cases (older iOS versions, multi-selection via PHPicker, or
// editing disabled) don't supply `.editedImage`. In those cases we:
// a) pull `.originalImage`,
// b) apply `cropRect` *before* touching orientation, and
// c) call `fixOrientation()` once at the end to normalize the bitmap.
guard let originalImage = mediaInfo[.originalImage] as? UIImage else {
return nil
}
if shouldReadCroppedImage,
let cropRect = mediaInfo[.cropRect] as? CGRect,
let cropped = ImageUtils.crop(image: originalImage, to: cropRect) {
// Crop first (rect is defined in the original pixel space), then rotate.
return cropped.fixOrientation()
}
// No editing only remove any EXIF orientation so JavaScript consumers see
// an upright image.
return originalImage.fixOrientation()
}
static func crop(image: UIImage, to: CGRect) -> UIImage? {
guard let cgImage = image.cgImage?.cropping(to: to) else {
return nil
}
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
static func readDataAndFileExtension(
image: UIImage,
mediaInfo: MediaInfo,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
// nil when an image is picked from camera
let referenceUrl = mediaInfo[.referenceURL] as? URL
switch referenceUrl?.absoluteString {
case .some(let s) where s.contains("ext=PNG"):
let data = image.pngData()
return (data, ".png")
case .some(let s) where s.contains("ext=WEBP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".webp")
case .some(let s) where s.contains("ext=BMP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".bmp")
case .some(let s) where s.contains("ext=GIF"):
var rawData: Data?
if let imgUrl = mediaInfo[.imageURL] as? URL {
rawData = try? Data(contentsOf: imgUrl)
}
let inputData = rawData ?? image.jpegData(compressionQuality: options.quality)
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
let cropRect = options.allowsEditing ? mediaInfo[.cropRect] as? CGRect : nil
let gifData = try processGifData(
inputData: inputData,
compressionQuality: options.quality,
initialMetadata: metadata,
cropRect: cropRect
)
return (gifData, ".gif")
default:
let data = image.jpegData(compressionQuality: options.quality)
return (data, ".jpg")
}
}
static func readDataAndFileExtension(
image: UIImage,
rawData: Data,
itemProvider: NSItemProvider,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
let preferredFormat = itemProvider.registeredTypeIdentifiers.first
switch preferredFormat {
case UTType.bmp.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".bmp")
case UTType.png.identifier:
let data = image.pngData()
return (data, ".png")
case UTType.webP.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".webp")
case UTType.gif.identifier:
let gifData = try processGifData(
inputData: rawData,
compressionQuality: options.quality,
initialMetadata: nil
)
return (gifData, ".gif")
case UTType.heic.identifier:
return (rawData, ".heic")
case UTType.tiff.identifier:
return (rawData, ".tiff")
case UTType.avif.identifier:
return (rawData, ".avif")
default:
if options.quality >= 1.0 {
return (rawData, ".jpg")
}
let data = image.jpegData(compressionQuality: options.quality)
return (data, ".jpg")
}
}
static func write(imageData: Data?, to: URL) throws {
do {
try imageData?.write(to: to, options: [.atomic])
} catch {
throw FailedToWriteImageException()
.causedBy(error)
}
}
/**
@returns `true` upon copying success and `false` otherwise
*/
static func tryCopyingOriginalImageFrom(mediaInfo: MediaInfo, to: URL) -> Bool {
guard let from = mediaInfo[.imageURL] as? URL else {
return false
}
do {
try FileManager.default.copyItem(atPath: from.path, toPath: to.path)
return true
} catch {
return false
}
}
/**
Reads base64 representation of the image data. If the data is `nil` fallbacks to reading the data from the url.
*/
static func readBase64From(imageData: Data?, orImageFileUrl url: URL, tryReadingFile: Bool) throws
-> String? {
if tryReadingFile {
do {
let data = try Data(contentsOf: url)
return data.base64EncodedString()
} catch {
throw FailedToReadImageDataException()
.causedBy(error)
}
}
guard let data = imageData else {
throw FailedToReadImageDataForBase64Exception()
}
return data.base64EncodedString()
}
static func readExifFrom(mediaInfo: MediaInfo) async -> ExifInfo? {
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if let metadata {
return ImageUtils.readExifFrom(imageMetadata: metadata)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return nil
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return nil
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
return await withCheckedContinuation { continuation in
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return continuation.resume(returning: nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return continuation.resume(returning: exif)
}
}
}
static func optionallyReadExifFrom(
mediaInfo: MediaInfo,
shouldReadExif: Bool,
completion: @escaping (_ result: ExifInfo?) -> Void
) {
if !shouldReadExif {
return completion(nil)
}
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if let metadata {
let exif = ImageUtils.readExifFrom(imageMetadata: metadata)
return completion(exif)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return completion(nil)
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return completion(exif)
}
}
static func readExifFrom(data: Data) -> ExifInfo? {
if let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil) {
if let properties = CGImageSourceCopyPropertiesAtIndex(cgImageSource, 0, nil)
as? [String: Any] {
return ImageUtils.readExifFrom(imageMetadata: properties)
}
}
return nil
}
static func readExifFrom(imageMetadata: [String: Any]) -> ExifInfo {
var exif: ExifInfo = imageMetadata[kCGImagePropertyExifDictionary as String] as? ExifInfo ?? [:]
// Copy ["{GPS}"]["<tag>"] to ["GPS<tag>"]
if let gps = imageMetadata[kCGImagePropertyGPSDictionary as String] as? [String: Any] {
gps.forEach { key, value in
exif["GPS\(key)"] = value
}
}
if let tiff = imageMetadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any] {
// Inject tiff data (make, model, resolution...)
exif.merge(tiff) { current, _ in current }
}
return exif
}
static func processGifData(
inputData: Data?,
compressionQuality: Double?,
initialMetadata: [String: Any]?,
cropRect: CGRect? = nil
) throws -> Data? {
let quality = compressionQuality ?? MAXIMUM_QUALITY
// for uncropped, maximum quality image we can just pass through the raw data
if cropRect == nil && quality >= MAXIMUM_QUALITY {
return inputData
}
guard let sourceData = inputData,
let imageSource = CGImageSourceCreateWithData(sourceData as CFData, nil)
else {
throw FailedToReadImageException()
}
let gifProperties = CGImageSourceCopyProperties(imageSource, nil) as? [String: Any]
let frameCount = CGImageSourceGetCount(imageSource)
let destinationData = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(destinationData, UTType.gif.identifier as CFString, frameCount, nil) else {
throw FailedToCreateGifException()
}
let gifMetadata = initialMetadata ?? gifProperties
CGImageDestinationSetProperties(imageDestination, gifMetadata as CFDictionary?)
for frameIndex in 0..<frameCount {
guard var cgImage = CGImageSourceCreateImageAtIndex(imageSource, frameIndex, nil) else {
throw FailedToCreateGifException()
}
var frameProperties =
CGImageSourceCopyPropertiesAtIndex(imageSource, frameIndex, nil) as? [String: Any] ?? [:]
if let cropRect {
cgImage = cgImage.cropping(to: cropRect) ?? cgImage
}
frameProperties[kCGImageDestinationLossyCompressionQuality as String] = quality
CGImageDestinationAddImage(imageDestination, cgImage, frameProperties as CFDictionary)
}
if !CGImageDestinationFinalize(imageDestination) {
throw FailedToExportGifException()
}
return destinationData as Data
}
/*
* Extracts the visual dimensions from an image file, accounting for EXIF orientation.
* This ensures portrait images return portrait dimensions (height > width).
* @param url The file URL of the image to analyze
* @return A CGSize containing the visual width and height, or nil if the dimensions cannot be determined
*/
static func readVisualSizeFrom(url: URL) -> CGSize? {
// Try to fetch the dimensions from the image metadata as this is the fastest way
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else {
// Fallback: minimally decode the image using UIImage when metadata is not available
if let img = UIImage(contentsOfFile: url.path) {
return CGSize(width: img.size.width, height: img.size.height)
}
return nil
}
// Check EXIF orientation to determine if dimensions should be swapped
let orientation = properties[kCGImagePropertyOrientation] as? Int ?? 1
// Orientations 5,6,7,8 (left/right rotated) need dimension swapping
if orientation >= 5 && orientation <= 8 {
return CGSize(width: height, height: width) // Swap for portrait
}
// Keep as is for landscape
return CGSize(width: width, height: height)
}
}
+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)
}
}
}
@@ -0,0 +1,32 @@
import Foundation
import Photos
internal extension NSItemProvider {
func loadObject(ofClass objectClass: any NSItemProviderReading.Type) async throws -> NSItemProviderReading? {
return try await withCheckedThrowingContinuation { continuation in
self.loadObject(ofClass: objectClass) { result, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: result)
}
}
}
func loadImageDataRepresentation() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
let preferredTypeIdentifier = self.registeredTypeIdentifiers()
.compactMap { UTType($0) }
.first { $0.conforms(to: .image) }?
.identifier ?? UTType.image.identifier
loadDataRepresentation(forTypeIdentifier: preferredTypeIdentifier) { data, error in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: FailedToReadImageException().causedBy(error))
}
}
}
}
}
@@ -0,0 +1,52 @@
// Copyright 2022-present 650 Industries. All rights reserved.
/**
Since iOS 11, launching ImagePicker with `allowsEditing` option makes cropping rectangle
slightly moved upwards, because of StatusBar visibility.
Hiding StatusBar during picking process solves the displacement issue.
See https://forums.developer.apple.com/thread/98274
*/
internal class StatusBarVisibilityController {
private var shouldRestoreStatusBarVisibility = false
func maybePreserveVisibilityAndHideStatusBar(_ shouldHideStatusBar: Bool) {
guard shouldHideStatusBar && !UIApplication.shared.isStatusBarHidden else {
return
}
shouldRestoreStatusBarVisibility = true
setStatusBarHidden(true)
}
func maybeRestoreStatusBarVisibility() {
guard shouldRestoreStatusBarVisibility else {
return
}
shouldRestoreStatusBarVisibility = false
setStatusBarHidden(false)
}
/**
Calling -[UIApplication setStatusBarHidden:withAnimation:] triggers a warning
that should be suppressable with -Wdeprecated-declarations, but is not.
The warning suggests to use -[UIViewController prefersStatusBarHidden].
Unfortunately until we stop presenting view controllers on detached VCs
the setting doesn't have any effect and we need to set status bar like that.
*/
private func setStatusBarHidden(_ hidden: Bool) {
let selector = NSSelectorFromString("setStatusBarHidden:withAnimation:")
UIApplication.shared.perform(selector, with: hidden, with: false)
// TODO: (@bbarthec) below is possible alternative
// let obj = X()
// let sel = #selector(obj.sayHiTo)
// let meth = class_getInstanceMethod(object_getClass(obj), sel)
// let imp = method_getImplementation(meth)
//
// typealias ClosureType = @convention(c) (AnyObject, Selector, String) -> Void
// let sayHiTo : ClosureType = unsafeBitCast(imp, ClosureType.self)
// sayHiTo(obj, sel, "Fabio")
// prints "Hello Fabio!"
}
}
@@ -0,0 +1,80 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import UIKit
extension UIImage {
func fixOrientation() -> UIImage? {
if self.imageOrientation == UIImage.Orientation.up {
return self
}
var transform = CGAffineTransform.identity
// rotation
switch self.imageOrientation {
case .down,
.downMirrored:
transform = transform
.translatedBy(x: self.size.width, y: self.size.height)
.rotated(by: .pi)
case .left,
.leftMirrored:
transform = transform
.translatedBy(x: self.size.width, y: 0)
.rotated(by: .pi / 2)
case .right,
.rightMirrored:
transform = transform
.translatedBy(x: 0, y: self.size.height)
.rotated(by: -.pi / 2)
default:
break
}
// mirroring
switch self.imageOrientation {
case .upMirrored,
.downMirrored:
transform = transform
.translatedBy(x: self.size.width, y: 0)
.scaledBy(x: -1, y: 1)
case .leftMirrored,
.rightMirrored:
transform = transform
.translatedBy(x: self.size.height, y: 0)
.scaledBy(x: -1, y: 1)
default:
break
}
guard let cgImage = self.cgImage,
let colorSpace = cgImage.colorSpace,
let ctx = CGContext(data: nil,
width: Int(self.size.width),
height: Int(self.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: cgImage.bitmapInfo.rawValue)
else {
return nil
}
ctx.concatenate(transform)
switch self.imageOrientation {
case .left,
.leftMirrored,
.right,
.rightMirrored:
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.height, height: self.size.width))
default:
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
}
guard let resultCgImage = ctx.makeImage() else {
return nil
}
return UIImage(cgImage: resultCgImage)
}
}
@@ -0,0 +1,57 @@
// Copyright 2016-present 650 Industries. All rights reserved.
extension UIImagePickerController {
func fixCannotMoveEditingBox() {
if let cropView = cropView,
let scrollView = scrollView,
scrollView.contentOffset.y == 0 {
let top = cropView.frame.minY + self.view.safeAreaInsets.top
let bottom = scrollView.frame.height - cropView.frame.height - top
scrollView.contentInset = UIEdgeInsets(top: top, left: 0, bottom: bottom, right: 0)
var offset: CGFloat = 0
if scrollView.contentSize.height > scrollView.contentSize.width {
offset = 0.5 * (scrollView.contentSize.height - scrollView.contentSize.width)
}
scrollView.contentOffset = CGPoint(x: 0, y: -top + offset)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.fixCannotMoveEditingBox()
}
}
var cropView: UIView? {
return findCropView(from: self.view)
}
var scrollView: UIScrollView? {
return findScrollView(from: self.view)
}
func findCropView(from view: UIView) -> UIView? {
let width = UIScreen.main.bounds.width
let size = view.bounds.size
if width == size.height, width == size.height {
return view
}
for view in view.subviews {
if let cropView = findCropView(from: view) {
return cropView
}
}
return nil
}
func findScrollView(from view: UIView) -> UIScrollView? {
if let scrollView = view as? UIScrollView {
return scrollView
}
for view in view.subviews {
if let scrollView = findScrollView(from: view) {
return scrollView
}
}
return nil
}
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import AVFoundation
import UniformTypeIdentifiers
import Photos
import ExpoModulesCore
internal struct VideoUtils {
static func tryCopyingVideo(at: URL, to: URL) throws {
do {
// we copy the file as `moveItem(at:,to:)` throws an error in iOS 13 due to missing permissions
try FileManager.default.copyItem(at: at, to: to)
} catch {
throw FailedToPickVideoException()
.causedBy(error)
}
}
/**
@returns duration in milliseconds
*/
static func readDurationFrom(url: URL) -> Double {
let asset = AVURLAsset(url: url)
return Double(asset.duration.value) / Double(asset.duration.timescale) * 1000
}
static func readSizeFrom(url: URL) -> CGSize? {
let asset = AVURLAsset(url: url)
guard let assetTrack = asset.tracks(withMediaType: .video).first else {
return nil
}
// The video could be rotated and the resulting transform can result in a negative width/height.
let size = assetTrack.naturalSize.applying(assetTrack.preferredTransform)
return CGSize(width: abs(size.width), height: abs(size.height))
}
static func readVideoUrlFrom(mediaInfo: MediaInfo) -> URL? {
return mediaInfo[.mediaURL] as? URL ?? mediaInfo[.referenceURL] as? URL
}
/**
Asynchronously transcodes asset provided as `sourceAssetUrl` according to `exportPreset`.
Result URL is returned to the `completion` closure.
Transcoded video is saved at `destinationUrl`, unless `exportPreset` is set to `passthrough`.
In this case, `sourceAssetUrl` is returned.
*/
static func transcodeVideoAsync(
sourceAssetUrl: URL,
destinationUrl: URL,
outputFileType: AVFileType,
exportPreset: VideoExportPreset
) async throws -> URL {
if case .passthrough = exportPreset {
return sourceAssetUrl
}
let asset = AVURLAsset(url: sourceAssetUrl)
let preset = exportPreset.toAVAssetExportPreset()
let canBeTranscoded = await AVAssetExportSession.compatibility(ofExportPreset: preset, with: asset, outputFileType: outputFileType)
guard canBeTranscoded else {
throw UnsupportedVideoExportPresetException(preset.description)
}
guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else {
throw FailedToTranscodeVideoException()
}
exportSession.outputFileType = outputFileType
exportSession.outputURL = destinationUrl
await exportSession.export()
if case exportSession.status = .failed {
let error = exportSession.error
throw FailedToTranscodeVideoException().causedBy(error)
}
return destinationUrl
}
static func loadVideoRepresentation(provider: NSItemProvider, urlTransformer: @escaping (URL) throws -> URL) async throws -> URL {
return try await withCheckedThrowingContinuation { continuation in
let typeId = UTType.movie.identifier
provider.loadFileRepresentation(forTypeIdentifier: typeId) { url, error in
guard let url else {
return continuation.resume(throwing: FailedToReadVideoException().causedBy(error))
}
do {
// The provided URL is only temporary the system deletes that file when the completion handler returns.
// Since we're using it asynchronously, we need to copy the video to another location.
let newUrl = try urlTransformer(url)
try VideoUtils.tryCopyingVideo(at: url, to: newUrl)
continuation.resume(returning: newUrl)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}