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

570 lines
19 KiB
Swift

import ExpoModulesCore
import PhotosUI
public class MediaLibraryModule: Module, PhotoLibraryObserverHandler {
private var allAssetsFetchResult: PHFetchResult<PHAsset>?
private var writeOnly = false
private var delegates = Set<SaveToLibraryDelegate>()
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
])
}
}
}
}
}