import ExpoModulesCore import PhotosUI public class MediaLibraryModule: Module, PhotoLibraryObserverHandler { private var allAssetsFetchResult: PHFetchResult? private var writeOnly = false private var delegates = Set() private var changeDelegate: PhotoLibraryObserver? // swiftlint:disable:next cyclomatic_complexity public func definition() -> ModuleDefinition { Name("ExpoMediaLibrary") Events("mediaLibraryDidChange") Constant("MediaType") { [ "audio": "audio", "photo": "photo", "video": "video", "unknown": "unknown", "all": "all" ] } Constant("SortBy") { [ "default": "default", "creationTime": "creationTime", "modificationTime": "modificationTime", "mediaType": "mediaType", "width": "width", "height": "height", "duration": "duration" ] } Constant("CHANGE_LISTENER_NAME") { "mediaLibraryDidChange" } OnCreate { appContext?.permissions?.register([ MediaLibraryPermissionRequester(), MediaLibraryWriteOnlyPermissionRequester() ]) } AsyncFunction("getPermissionsAsync") { (writeOnly: Bool, promise: Promise) in self.writeOnly = writeOnly appContext? .permissions? .getPermissionUsingRequesterClass( requesterClass(writeOnly), resolve: promise.resolver, reject: promise.legacyRejecter ) } AsyncFunction("requestPermissionsAsync") { (writeOnly: Bool, promise: Promise) in self.writeOnly = writeOnly appContext? .permissions? .askForPermission( usingRequesterClass: requesterClass(writeOnly), resolve: promise.resolver, reject: promise.legacyRejecter ) } AsyncFunction("presentPermissionsPickerAsync") { #if os(iOS) guard let vc = appContext?.utilities?.currentViewController() else { return } PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc) #endif }.runOnQueue(.main) AsyncFunction("createAssetAsync") { (uri: URL, albumId: String?, promise: Promise) in if !checkPermissions(promise: promise) { return } createAsset(uri: uri, appContext: appContext) { asset, error in guard let asset else { promise.reject(error ?? SaveAssetException(nil)) return } guard let albumId else { promise.resolve(exportAsset(asset: asset)) return } addAssets(ids: [asset.localIdentifier], to: albumId) { success, error in if success { promise.resolve(exportAsset(asset: asset)) } else { promise.reject(error ?? SaveAssetException(nil)) } } } } AsyncFunction("saveToLibraryAsync") { (localUrl: URL, promise: Promise) in #if os(iOS) if Bundle.main.infoDictionary?["NSPhotoLibraryAddUsageDescription"] == nil { throw MissingPListKeyException("NSPhotoLibraryAddUsageDescription") } if localUrl.pathExtension.isEmpty { promise.reject(EmptyFileExtensionException()) return } let assetType = assetType(for: localUrl) let delegate = SaveToLibraryDelegate() delegates.insert(delegate) let callback: SaveToLibraryCallback = { [weak self] _, error in guard let self else { return } self.delegates.remove(delegate) guard error == nil else { promise.reject(SaveAssetException(error)) return } promise.resolve() } if assetType == .image { if localUrl.pathExtension.lowercased() == "gif" { delegate.writeGIF(localUrl, withCallback: callback) return } guard let image = UIImage(data: try Data(contentsOf: localUrl)) else { promise.reject(MissingFileException(localUrl.absoluteString)) return } delegate.writeImage(image, withCallback: callback) return } else if assetType == .video { if UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(localUrl.path) { delegate.writeVideo(localUrl, withCallback: callback) return } promise.reject(SaveVideoException()) return } #endif promise.reject(UnsupportedAssetException()) } AsyncFunction("addAssetsToAlbumAsync") { (assetIds: [String], album: String, promise: Promise) in runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { addAssets(ids: assetIds, to: album) { success, error in if success { promise.resolve(success) } else { promise.reject(SaveAlbumException(error)) } } } } AsyncFunction("removeAssetsFromAlbumAsync") { (assetIds: [String], album: String, promise: Promise) in runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { PHPhotoLibrary.shared().performChanges { guard let collection = getAlbum(by: album) else { return } let assets = getAssetsBy(assetIds: assetIds) let albumChangeRequest = PHAssetCollectionChangeRequest(for: collection, assets: assets) albumChangeRequest?.removeAssets(assets) } completionHandler: { success, error in if success { promise.resolve(success) } else { promise.reject(RemoveFromAlbumException(error)) } } } } AsyncFunction("deleteAssetsAsync") { (assetIds: [String], promise: Promise) in if !checkPermissions(promise: promise) { return } PHPhotoLibrary.shared().performChanges { let fetched = PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil) PHAssetChangeRequest.deleteAssets(fetched) } completionHandler: { success, error in if success { promise.resolve(success) } else { promise.reject(RemoveAssetsException(error)) } } } AsyncFunction("getAlbumsAsync") { (options: AlbumOptions, promise: Promise) in runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { var albums = [[String: Any?]?]() let fetchOptions = PHFetchOptions() fetchOptions.includeHiddenAssets = false fetchOptions.includeAllBurstAssets = false let useAlbumsfetchResult = PHCollectionList.fetchTopLevelUserCollections(with: fetchOptions) let collections = exportCollections(collections: useAlbumsfetchResult, with: fetchOptions, in: nil) albums.append(contentsOf: collections) if options.includeSmartAlbums { let smartAlbumsFetchResult = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any, options: fetchOptions) albums.append(contentsOf: exportCollections(collections: smartAlbumsFetchResult, with: fetchOptions, in: nil)) } promise.resolve(albums) } } AsyncFunction("getMomentsAsync") { (promise: Promise) in if !checkPermissions(promise: promise) { return } let options = PHFetchOptions() options.includeHiddenAssets = false options.includeAllBurstAssets = false let fetchResult = PHAssetCollection.fetchMoments(with: options) let albums = exportCollections(collections: fetchResult, with: options, in: nil) promise.resolve(albums) } AsyncFunction("getAlbumAsync") { (title: String, promise: Promise) in runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { let collection = getAlbum(with: title) promise.resolve(exportCollection(collection)) } } AsyncFunction("createAlbumAsync") { (title: String, assetId: String?, initialAssetUri: URL?, promise: Promise) in runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { createAlbum(with: title) { [weak self] collection, createError in guard let collection else { promise.reject(CreateAlbumFailedException(createError)) return } if assetId == nil && initialAssetUri == nil { promise.resolve(exportCollection(collection)) return } if let assetId { addAssets(ids: [assetId], to: collection.localIdentifier) { success, addError in if success { promise.resolve(exportCollection(collection)) } else { promise.reject(FailedToAddAssetException(addError)) } } return } if let initialAssetUri { createAsset(uri: initialAssetUri, appContext: self?.appContext) { asset, error in if let error { promise.reject(error) return } if let asset { addAssets(ids: [asset.localIdentifier], to: collection.localIdentifier) { success, addError in if success { promise.resolve(exportCollection(collection)) } else { promise.reject(FailedToAddAssetException(addError)) } } } } } } } } AsyncFunction("deleteAlbumsAsync") { (albumIds: [String], removeAsset: Bool, promise: Promise) in runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { let collections = getAlbums(by: albumIds) PHPhotoLibrary.shared().performChanges { if removeAsset { collections.enumerateObjects { collection, _, _ in let fetch = PHAsset.fetchAssets(in: collection, options: nil) PHAssetChangeRequest.deleteAssets(fetch) } } PHAssetCollectionChangeRequest.deleteAssetCollections(collections) } completionHandler: { success, error in if success { promise.resolve(success) } else { promise.reject(DeleteAlbumFailedException(error)) } } } } AsyncFunction("getAssetInfoAsync") { (assetId: String?, options: AssetInfoOptions, promise: Promise) in if !checkPermissions(promise: promise) { return } guard let asset = getAssetBy(id: assetId) else { promise.resolve(nil) return } if asset.mediaType == .image { resolveImage(asset: asset, options: options, promise: promise) } else { resolveVideo(asset: asset, options: options, promise: promise) } } AsyncFunction("getAssetsAsync") { (options: AssetWithOptions, promise: Promise) in if !checkPermissions(promise: promise) { return } if let albumId = options.album { runIfAllPermissionsWereGranted(reject: promise.legacyRejecter) { let collection = getAlbum(by: albumId) getAssetsWithAfter(options: options, collection: collection, promise: promise) } } else { getAssetsWithAfter(options: options, collection: nil, promise: promise) } } OnStartObserving { allAssetsFetchResult = getAllAssets() let delegate = PhotoLibraryObserver(handler: self) self.changeDelegate = delegate PHPhotoLibrary.shared().register(delegate) } OnStopObserving { changeDelegate = nil allAssetsFetchResult = nil } } private func handleLivePhoto(asset: PHAsset, shouldDownloadFromNetwork: Bool, result: [String: Any?], promise: Promise) { let livePhotoOptions = PHLivePhotoRequestOptions() livePhotoOptions.isNetworkAccessAllowed = shouldDownloadFromNetwork var updatedResult = result updatedResult["pairedVideoAsset"] = nil PHImageManager.default() .requestLivePhoto(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: livePhotoOptions) { livePhoto, _ in guard let livePhoto = livePhoto, let videoResource = PHAssetResource.assetResources(for: livePhoto) .first(where: { $0.type == .pairedVideo }) else { promise.resolve(updatedResult) return } self.writePairedVideoAsset(videoResource: videoResource, asset: asset, result: updatedResult, promise: promise) } } private func writePairedVideoAsset(videoResource: PHAssetResource, asset: PHAsset, result: [String: Any?], promise: Promise) { let fileName = videoResource.originalFilename let tempDir = FileManager.default.temporaryDirectory let fileExt = getFileExtension(from: fileName).replacingOccurrences(of: ".", with: "") let tempId = UUID().uuidString let fileUrl = tempDir.appendingPathComponent(tempId).appendingPathExtension(fileExt) var width = CGFloat(asset.pixelWidth) var height = CGFloat(asset.pixelHeight) PHAssetResourceManager.default().writeData(for: videoResource, toFile: fileUrl, options: nil) { error in guard error == nil else { promise.resolve(result) return } let avAsset = AVAsset(url: fileUrl) let duration = avAsset.duration.seconds // The video resouece of a paired photo may have different dimensions from the original photo if let videoSize = readSizeFrom(url: fileUrl) { width = videoSize.width height = videoSize.height } let pairedVideoAsset: [String: Any?] = [ "id": tempId, "filename": fileName, "uri": fileUrl.absoluteString, "mediaType": "pairedVideo", "mediaSubtypes": [], "width": width, "height": height, "duration": duration, "creationTime": exportDate(asset.creationDate), "modificationTime": exportDate(asset.modificationDate) ] var updatedResult = result updatedResult["pairedVideoAsset"] = pairedVideoAsset promise.resolve(updatedResult) } } private func resolveImage(asset: PHAsset, options: AssetInfoOptions, promise: Promise) { var result = exportAssetInfo(asset: asset) ?? [:] let imageOptions = PHContentEditingInputRequestOptions() imageOptions.isNetworkAccessAllowed = options.shouldDownloadFromNetwork asset.requestContentEditingInput(with: imageOptions) { contentInput, info in result["localUri"] = contentInput?.fullSizeImageURL?.absoluteString result["orientation"] = contentInput?.fullSizeImageOrientation if !options.shouldDownloadFromNetwork { result["isNetworkAsset"] = info[PHContentEditingInputResultIsInCloudKey] ?? false } if let url = contentInput?.fullSizeImageURL, let ciImage = CIImage(contentsOf: url) { result["exif"] = ciImage.properties } result["pairedVideoAsset"] = nil if asset.mediaSubtypes.contains(.photoLive) { self.handleLivePhoto(asset: asset, shouldDownloadFromNetwork: options .shouldDownloadFromNetwork, result: result, promise: promise) } else { promise.resolve(result) } } } private func resolveVideo(asset: PHAsset, options: AssetInfoOptions, promise: Promise) { var result = exportAssetInfo(asset: asset) ?? [:] let videoOptions = PHVideoRequestOptions() videoOptions.isNetworkAccessAllowed = options.shouldDownloadFromNetwork PHImageManager.default().requestAVAsset(forVideo: asset, options: videoOptions) { asset, _, info in guard let asset = asset as? AVComposition else { let urlAsset = asset as? AVURLAsset result["localUri"] = urlAsset?.url.absoluteString if !options.shouldDownloadFromNetwork { result["isNetworkAsset"] = info?[PHImageResultIsInCloudKey] ?? false } promise.resolve(result) return } let directory = self.appContext?.config.cacheDirectory?.appendingPathComponent("MediaLibrary") FileSystemUtilities.ensureDirExists(at: directory) let videoOutputFileName = "slowMoVideo-\(Int.random(in: 0...999)).mov" guard let videoFileOutputPath = directory?.appendingPathComponent(videoOutputFileName) else { promise.reject(InvalidPathException()) return } let videoFileOutputURL = URL(string: videoFileOutputPath.path) let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) exporter?.outputURL = videoFileOutputURL exporter?.outputFileType = AVFileType.mov exporter?.shouldOptimizeForNetworkUse = true exporter?.exportAsynchronously { switch exporter?.status { case .completed: result["localUri"] = videoFileOutputURL?.absoluteString if !options.shouldDownloadFromNetwork { result["isNetworkAsset"] = info?[PHImageResultIsInCloudKey] ?? false } promise.resolve(result) case .failed: promise.reject(ExportSessionFailedException()) case .cancelled: promise.reject(ExportSessionCancelledException()) default: promise.reject(ExportSessionUnknownException()) } } } } private func checkPermissions(promise: Promise) -> Bool { guard let permissions = appContext?.permissions else { promise.reject(MediaLibraryPermissionsException()) return false } if !permissions.hasGrantedPermission(usingRequesterClass: requesterClass(self.writeOnly)) { promise.reject(MediaLibraryPermissionsException()) return false } return true } private func runIfAllPermissionsWereGranted(reject: @escaping EXPromiseRejectBlock, block: @escaping () -> Void) { appContext?.permissions?.getPermissionUsingRequesterClass( MediaLibraryPermissionRequester.self, resolve: { result in if let permissions = result as? [String: Any] { if permissions["status"] as? String != "granted" { reject("E_NO_PERMISSIONS", "MEDIA_LIBRARY permission is required to do this operation.", nil) return } if permissions["accessPrivileges"] as? String != "all" { reject("E_NO_PERMISSIONS", "MEDIA_LIBRARY permission is required to do this operation.", nil) return } block() } }, reject: reject) } func didChange(_ changeInstance: PHChange) { if let allAssetsFetchResult { let changeDetails = changeInstance.changeDetails(for: allAssetsFetchResult) if let changeDetails { self.allAssetsFetchResult = changeDetails.fetchResultAfterChanges if changeDetails.hasIncrementalChanges && !changeDetails.insertedObjects.isEmpty || !changeDetails.removedObjects.isEmpty { var insertedAssets = [[String: Any?]?]() var deletedAssets = [[String: Any?]?]() var updatedAssets = [[String: Any?]?]() for asset in changeDetails.insertedObjects { insertedAssets.append(exportAsset(asset: asset)) } for asset in changeDetails.removedObjects { deletedAssets.append(exportAsset(asset: asset)) } for asset in changeDetails.changedObjects { updatedAssets.append(exportAsset(asset: asset)) } let body: [String: Any] = [ "hasIncrementalChanges": true, "insertedAssets": insertedAssets, "deletedAssets": deletedAssets, "updatedAssets": updatedAssets ] sendEvent("mediaLibraryDidChange", body) return } if !changeDetails.hasIncrementalChanges { sendEvent("mediaLibraryDidChange", [ "hasIncrementalChanges": false ]) } } } } }