Files
Schnappix/node_modules/expo-media-library/ios/MediaLibraryUtilities.swift
T

540 lines
16 KiB
Swift

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[..<range.lowerBound])
}
return nil
}
func assetUriForLocalId(localId: String) -> String {
return String(format: "ph://\(localId)")
}
func getAllAssets() -> PHFetchResult<PHAsset> {
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<PHAssetCollection> {
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<PHAsset> {
let options = PHFetchOptions()
options.includeHiddenAssets = true
options.includeAllBurstAssets = true
options.fetchLimit = assetIds.count
return PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: options)
}
func exportCollections(
collections: PHFetchResult<PHCollection>,
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<PHAssetCollection>, 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<PHAsset> = 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<PHAsset>,
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..<endIndex {
let asset = fetchResult.object(at: index)
let exportedAsset = exportAsset(asset: asset)
assets.append(exportedAsset)
}
hasNextPage = endIndex < totalCount
}
return GetAssetsResponse(assets: assets, totalCount: totalCount, hasNextPage: hasNextPage)
}
func prepareSortDescriptors(sortBy: [String]) throws -> [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)"
}