import ExpoModulesCore import Photos import CoreServices func exportDate(_ date: Date?) -> Double? { if let date = date { let interval = date.timeIntervalSince1970 return interval * 1000 } return nil } func stringifyAlbumType(type: PHAssetCollectionType) -> String { switch type { case.album: return "album" case .moment: return "moment" case .smartAlbum: return "smartAlbum" @unknown default: log.error("Unhandled `PHAssetCollectionType` value: \(type), returning `album` as fallback. Add the missing case as soon as possible.") return "album" } } func exportAssetInfo(asset: PHAsset) -> [String: Any?] { var assetDict = exportAsset(asset: asset) assetDict["location"] = exportLocation(location: asset.location) assetDict["isFavorite"] = asset.isFavorite assetDict["isHidden"] = asset.isHidden return assetDict } func exportAsset(asset: PHAsset) -> [String: Any?] { let fileName = asset.value(forKey: "filename") return [ "id": asset.localIdentifier, "filename": fileName, "uri": assetUriForLocalId(localId: asset.localIdentifier), "mediaType": MediaType(fromPHAssetMediaType: asset.mediaType).rawValue, "mediaSubtypes": MediaSubtype.stringify(asset.mediaSubtypes), "width": asset.pixelWidth, "height": asset.pixelHeight, // Uses required reason API based on the following reason: 0A2A.1 "creationTime": exportDate(asset.creationDate), // Uses required reason API based on the following reason: 0A2A.1 "modificationTime": exportDate(asset.modificationDate), "duration": asset.duration, "pairedVideoAsset": nil ] } func exportLocation(location: CLLocation?) -> [String: String]? { guard let location else { return nil } return [ "latitude": "\(location.coordinate.latitude)", "longitude": "\(location.coordinate.longitude)" ] } func assetIdFromLocalId(localId: String) -> String? { if let range = localId.range(of: "/.*", options: .regularExpression) { return String(localId[.. String { return String(format: "ph://\(localId)") } func getAllAssets() -> PHFetchResult { let options = PHFetchOptions() options.includeAssetSourceTypes = PHAssetSourceType.typeUserLibrary options.includeHiddenAssets = false options.includeHiddenAssets = false return PHAsset.fetchAssets(with: options) } func addAssets(ids: [String], to album: String, with callback: @escaping (Bool, Error?) -> Void) { PHPhotoLibrary.shared().performChanges({ guard let collection = getAlbum(by: album) else { return } let assets = getAssetsBy(assetIds: ids) let collectionAssets = PHAsset.fetchAssets(in: collection, options: nil) let albumChangeRequest = PHAssetCollectionChangeRequest(for: collection, assets: collectionAssets) albumChangeRequest?.addAssets(assets as NSFastEnumeration) }, completionHandler: callback) } func getAlbum(by id: String) -> PHAssetCollection? { let options = PHFetchOptions() options.fetchLimit = 1 return PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [id], options: options).firstObject } func getAlbum(with title: String) -> PHAssetCollection? { let options = PHFetchOptions() options.predicate = NSPredicate(format: "title == %@", title) options.fetchLimit = 1 let fetchResult = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: options) return fetchResult.firstObject } func getAlbums(by ids: [String]) -> PHFetchResult { let options = PHFetchOptions() return PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: ids, options: options) } func getAssetBy(id: String?) -> PHAsset? { if let id { return getAssetsBy(assetIds: [id]).firstObject } return nil } func getAssetsBy(assetIds: [String]) -> PHFetchResult { let options = PHFetchOptions() options.includeHiddenAssets = true options.includeAllBurstAssets = true options.fetchLimit = assetIds.count return PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: options) } func exportCollections( collections: PHFetchResult, with options: PHFetchOptions, in folder: String?) -> [[String: Any?]?] { var albums = [[String: Any?]?]() collections.enumerateObjects { collection, _, _ in if let assetCollection = collection as? PHAssetCollection { if let folder { albums.append( exportCollection(assetCollection, folderName: folder)) } else { albums.append(exportCollection(assetCollection, folderName: nil)) } } else if let collectionList = collection as? PHCollectionList { let collectionsInFolder = PHCollectionList.fetchCollections(in: collectionList, options: options) albums.append(contentsOf: exportCollections(collections: collectionsInFolder, with: options, in: collectionList.localizedTitle)) } } return albums } func exportCollections(collections: PHFetchResult, with options: PHFetchOptions, in folder: String?) -> [[String: Any?]?] { var albums = [[String: Any?]?]() collections.enumerateObjects { collection, _, _ in if let assetCollection = collection as? PHAssetCollection { if let folder { albums.append( exportCollection(assetCollection, folderName: folder)) } else { albums.append(exportCollection(assetCollection, folderName: nil)) } } else if let collectionList = collection as? PHCollectionList { let collectionsInFolder = PHCollectionList.fetchCollections(in: collectionList, options: options) albums.append(contentsOf: exportCollections(collections: collectionsInFolder, with: options, in: collectionList.localizedTitle)) } } return albums } func exportCollection(_ collection: PHAssetCollection?, folderName: String? = nil) -> [String: Any?]? { guard let collection else { return nil } return [ "id": assetIdFromLocalId(localId: collection.localIdentifier), "title": collection.localizedTitle, "folderName": folderName, "type": stringifyAlbumType(type: collection.assetCollectionType), "assetCount": assetCountOfCollection(collection), "startTime": exportDate(collection.startDate), "endTime": exportDate(collection.endDate), "approximateLocation": exportLocation(location: collection.approximateLocation), "locationNames": collection.localizedLocationNames ] } private func assetCountOfCollection(_ collection: PHAssetCollection) -> Int { if collection.estimatedAssetCount == NSNotFound { let options = PHFetchOptions() options.includeHiddenAssets = false options.includeAllBurstAssets = false return PHAsset.fetchAssets(in: collection, options: options).count } return collection.estimatedAssetCount } func ensureAlbumWith(title: String, completion: @escaping (PHAssetCollection?, Error?) -> Void) { if let collection = getAlbum(with: title) { completion(collection, nil) return } createAlbum(with: title, completion: completion) } func createAlbum(with title: String, completion: @escaping (PHAssetCollection?, Error?) -> Void) { var collectionPlaceholder: PHObjectPlaceholder? PHPhotoLibrary.shared().performChanges { let changeRequest = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: title) collectionPlaceholder = changeRequest.placeholderForCreatedAssetCollection } completionHandler: { success, error in if success { if let collectionPlaceholder { let fetchResult = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [collectionPlaceholder.localIdentifier], options: nil) completion(fetchResult.firstObject, nil) } } else { completion(nil, error) } } } func createAsset(uri: URL, appContext: AppContext?, completion: @escaping (PHAsset?, Error?) -> Void) { let assetType = assetType(for: uri) if uri.pathExtension.isEmpty { completion(nil, EmptyFileExtensionException()) return } if assetType == .unknown || assetType == .audio { completion(nil, UnsupportedAssetTypeException(uri.absoluteString)) return } guard FileSystemUtilities.permissions(appContext, for: uri).contains(.read) && FileManager.default.isReadableFile(atPath: uri.path) else { completion(nil, UnreadableAssetException(uri.absoluteString)) return } var assetPlaceholder: PHObjectPlaceholder? PHPhotoLibrary.shared().performChanges { let changeRequest = assetType == .video ? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: uri) : PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: uri) assetPlaceholder = changeRequest?.placeholderForCreatedAsset } completionHandler: { success, error in if success { let asset = getAssetBy(id: assetPlaceholder?.localIdentifier) completion(asset, nil) } else { completion(nil, SaveAssetException(error)) } } } func assetType(for localUri: URL) -> PHAssetMediaType { guard let type = UTType(filenameExtension: localUri.pathExtension) else { return assetTypeExtension(for: localUri.pathExtension) } switch type { case .image: return .image case .video, .movie: return .video case .audio: return .audio case _ where type.conforms(to: .image): return .image case _ where type.conforms(to: .video) || type.conforms(to: .movie): return .video case _ where type.conforms(to: .audio): return .audio default: return .unknown } } func assetTypeExtension(for fileExtension: String) -> PHAssetMediaType { let extensionLookupFallback: [String: PHAssetMediaType] = [ "jpeg": .image, "jpg": .image, "jpe": .image, "png": .image, "mp3": .audio, "mpga": .audio, "mov": .video, "qt": .video, "mpg": .video, "mpeg": .video, "mpe": .video, "m75": .video, "m15": .video, "m2v": .video, "ts": .video, "mp4": .video, "mpg4": .video, "m4p": .video, "avi": .video, "vfw": .video, "aiff": .audio, "aif": .audio, "wav": .audio, "wave": .audio, "bwf": .audio, "midi": .audio, "mid": .audio, "smf": .audio, "kar": .audio, "tiff": .image, "tif": .image, "gif": .image, "qtif": .image, "qti": .image, "icns": .image ] let fallbackMediaType = extensionLookupFallback[fileExtension] if let fallbackMediaType { return fallbackMediaType } return .unknown } func getAssetsWithAfter(options: AssetWithOptions, collection: PHAssetCollection?, promise: Promise) { let fetchOptions = PHFetchOptions() var predicates: [NSPredicate] = [] var cursor: PHAsset? if let after = options.after { cursor = getAssetBy(id: after) if cursor == nil { promise.reject(CursorException()) return } } if !options.mediaType.isEmpty { let assetTypes = options.mediaType.contains(.all) ? nil : options.mediaType.map { NSNumber(value: $0.toPHMediaType().rawValue) } if let assetTypes { let predicate = NSPredicate(format: "mediaType IN %@", assetTypes) predicates.append(predicate) } } if !options.mediaSubtypes.isEmpty { // include assets that match at least one of the requested subtypes let mask = options.mediaSubtypes .map { $0.toPHAssetMediaSubtype().rawValue } .reduce(0, |) let subtypesPredicate = NSPredicate( format: "(mediaSubtypes & %d) != 0", mask ) predicates.append(subtypesPredicate) } if !options.sortBy.isEmpty { do { fetchOptions.sortDescriptors = try prepareSortDescriptors(sortBy: options.sortBy) } catch { promise.reject(error) return } } if let createdAfter = options.createdAfter { if let date = EXUtilities.nsDate(createdAfter) { let predicate = NSPredicate(format: "creationDate > %@", date as CVarArg) predicates.append(predicate) } } if let createdBefore = options.createdBefore { if let date = EXUtilities.nsDate(createdBefore) { let predicate = NSPredicate(format: "creationDate < %@", date as CVarArg) predicates.append(predicate) } } if !predicates.isEmpty { let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) fetchOptions.predicate = compoundPredicate } fetchOptions.includeAssetSourceTypes = PHAssetSourceType.typeUserLibrary fetchOptions.includeAllBurstAssets = false fetchOptions.includeHiddenAssets = false let fetchResult: PHFetchResult = if let collection { PHAsset.fetchAssets(in: collection, options: fetchOptions) } else { PHAsset.fetchAssets(with: fetchOptions) } let cursorIndex = if let cursor { fetchResult.index(of: cursor) } else { NSNotFound } let resultingAssets = getAssets( fetchResult: fetchResult, cursorIndex: cursorIndex, numOfRequestedItems: options.first, sortDescriptors: fetchOptions.sortDescriptors ) let lastAsset = resultingAssets.assets.last let response: [String: Any?] = [ "assets": resultingAssets.assets, "endCursor": lastAsset?["id"] ?? options.after, "hasNextPage": resultingAssets.hasNextPage, "totalCount": resultingAssets.totalCount ] promise.resolve(response) } func getAssets( fetchResult: PHFetchResult, cursorIndex: Int, numOfRequestedItems: Int, sortDescriptors: [NSSortDescriptor]? = nil ) -> GetAssetsResponse { let totalCount = fetchResult.count if totalCount == 0 { return GetAssetsResponse(assets: [], totalCount: totalCount, hasNextPage: false) } var assets: [[String: Any?]] = [] assets.reserveCapacity(numOfRequestedItems) var hasNextPage: Bool // If we don't use any sort descriptors (nil, or empty array), the assets are sorted just like in Photos app. // That means the most recent assets are at the start of the array. if sortDescriptors?.isEmpty ?? true { let upperIndex = max(cursorIndex == NSNotFound ? totalCount - 1 : cursorIndex - 1, -1) let lowerIndex = max(upperIndex - numOfRequestedItems, -1) for i in stride(from: upperIndex, to: lowerIndex, by: -1) { let asset = fetchResult.object(at: i) let exportedAsset = exportAsset(asset: asset) assets.append(exportedAsset) } hasNextPage = lowerIndex >= 0 } else { let startIndex = cursorIndex == NSNotFound ? 0 : cursorIndex + 1 let endIndex = min(startIndex + numOfRequestedItems, totalCount) for index in startIndex.. [NSSortDescriptor] { var descriptors = [NSSortDescriptor]() for config in sortBy { if let sortDescriptor = try sortDescriptor(from: config) { descriptors.append(sortDescriptor) } } return descriptors } private func sortDescriptor(from config: String) throws -> NSSortDescriptor? { let parts = config.components(separatedBy: " ") guard let key = try convertSortByKey(parts[0]) else { return nil } let ascending = parts.count > 1 && parts[1] == "ASC" return NSSortDescriptor(key: key, ascending: ascending) } private func convertSortByKey(_ key: String) throws -> String? { if key == "default" { return nil } let conversionDict: [String: String] = [ "creationTime": "creationDate", "modificationTime": "modificationDate", "mediaType": "mediaType", "width": "pixelWidth", "height": "pixelHeight", "duration": "duration" ] guard let value = conversionDict[key] else { throw SortByKeyException(key) } return value } func requesterClass(_ writeOnly: Bool) -> EXPermissionsRequester.Type { if writeOnly { return MediaLibraryWriteOnlyPermissionRequester.self } return MediaLibraryPermissionRequester.self } 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)) } func getFileExtension(from fileName: String) -> String { return ".\(URL(fileURLWithPath: fileName).pathExtension)" }