// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation import Photos import PromiseKit protocol PhotoLibraryDelegate: class { func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) } class PhotoMediaSize { var thumbnailSize: CGSize init() { self.thumbnailSize = .zero } init(thumbnailSize: CGSize) { self.thumbnailSize = thumbnailSize } } class PhotoPickerAssetItem: PhotoGridItem { let asset: PHAsset let photoCollectionContents: PhotoCollectionContents let photoMediaSize: PhotoMediaSize init(asset: PHAsset, photoCollectionContents: PhotoCollectionContents, photoMediaSize: PhotoMediaSize) { self.asset = asset self.photoCollectionContents = photoCollectionContents self.photoMediaSize = photoMediaSize } // MARK: PhotoGridItem var type: PhotoGridItemType { if asset.mediaType == .video { return .video } // TODO show GIF badge? return .photo } func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { var syncImageResult: UIImage? var hasLoadedImage = false // Surprisingly, iOS will opportunistically run the completion block sync if the image is // already available. photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in DispatchMainThreadSafe({ syncImageResult = image // Once we've _successfully_ completed (e.g. invoked the completion with // a non-nil image), don't invoke the completion again with a nil argument. if !hasLoadedImage || image != nil { completion(image) if image != nil { hasLoadedImage = true } } }) } return syncImageResult } } class PhotoCollectionContents { let fetchResult: PHFetchResult let localizedTitle: String? enum PhotoLibraryError: Error { case assertionError(description: String) case unsupportedMediaType } init(fetchResult: PHFetchResult, localizedTitle: String?) { self.fetchResult = fetchResult self.localizedTitle = localizedTitle } private let imageManager = PHCachingImageManager() // MARK: - Asset Accessors var assetCount: Int { return fetchResult.count } var lastAsset: PHAsset? { guard assetCount > 0 else { return nil } return asset(at: assetCount - 1) } var firstAsset: PHAsset? { guard assetCount > 0 else { return nil } return asset(at: 0) } func asset(at index: Int) -> PHAsset { return fetchResult.object(at: index) } // MARK: - AssetItem Accessors func assetItem(at index: Int, photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem { let mediaAsset = asset(at: index) return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize) } func firstAssetItem(photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? { guard let mediaAsset = firstAsset else { return nil } return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize) } func lastAssetItem(photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? { guard let mediaAsset = lastAsset else { return nil } return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize) } // MARK: ImageManager func requestThumbnail(for asset: PHAsset, thumbnailSize: CGSize, resultHandler: @escaping (UIImage?, [AnyHashable: Any]?) -> Void) { _ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler) } private func requestImageDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> { return Promise { resolver in let options: PHImageRequestOptions = PHImageRequestOptions() options.isNetworkAccessAllowed = true _ = imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in guard let imageData = imageData else { resolver.reject(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")) return } guard let dataUTI = dataUTI else { resolver.reject(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")) return } guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else { resolver.reject(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil")) return } resolver.fulfill((dataSource: dataSource, dataUTI: dataUTI)) } } } private func requestVideoDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> { return Promise { resolver in let options: PHVideoRequestOptions = PHVideoRequestOptions() options.isNetworkAccessAllowed = true _ = imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in guard let exportSession = exportSession else { resolver.reject(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")) return } exportSession.outputFileType = AVFileType.mp4 exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4") let exportURL = URL(fileURLWithPath: exportPath) exportSession.outputURL = exportURL Logger.debug("starting video export") exportSession.exportAsynchronously { Logger.debug("Completed video export") guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else { resolver.reject(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL")) return } resolver.fulfill((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)) } } } } func outgoingAttachment(for asset: PHAsset) -> Promise { switch asset.mediaType { case .image: return requestImageDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium) attachment.assetId = asset.localIdentifier return attachment } case .video: return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI) attachment.assetId = asset.localIdentifier return attachment } default: return Promise(error: PhotoLibraryError.unsupportedMediaType) } } } class PhotoCollection { private let collection: PHAssetCollection init(collection: PHAssetCollection) { self.collection = collection } func localizedTitle() -> String { guard let localizedTitle = collection.localizedTitle?.stripped, localizedTitle.count > 0 else { return NSLocalizedString("PHOTO_PICKER_UNNAMED_COLLECTION", comment: "label for system photo collections which have no name.") } return localizedTitle } func contents() -> PhotoCollectionContents { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] let fetchResult = PHAsset.fetchAssets(in: collection, options: options) return PhotoCollectionContents(fetchResult: fetchResult, localizedTitle: localizedTitle()) } } extension PhotoCollection: Equatable { static func == (lhs: PhotoCollection, rhs: PhotoCollection) -> Bool { return lhs.collection == rhs.collection } } class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { typealias WeakDelegate = Weak var delegates = [WeakDelegate]() public func add(delegate: PhotoLibraryDelegate) { delegates.append(WeakDelegate(value: delegate)) } var assetCollection: PHAssetCollection! func photoLibraryDidChange(_ changeInstance: PHChange) { DispatchQueue.main.async { for weakDelegate in self.delegates { weakDelegate.value?.photoLibraryDidChange(self) } } } override init() { super.init() PHPhotoLibrary.shared().register(self) } deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) } func defaultPhotoCollection() -> PhotoCollection { guard let photoCollection = allPhotoCollections().first else { owsFail("Could not locate Camera Roll.") } return photoCollection } func allPhotoCollections() -> [PhotoCollection] { var collections = [PhotoCollection]() var collectionIds = Set() let processPHCollection: ((collection: PHCollection, hideIfEmpty: Bool)) -> Void = { arg in let (collection, hideIfEmpty) = arg // De-duplicate by id. let collectionId = collection.localIdentifier guard !collectionIds.contains(collectionId) else { return } collectionIds.insert(collectionId) guard let assetCollection = collection as? PHAssetCollection else { owsFailDebug("Asset collection has unexpected type: \(type(of: collection))") return } let photoCollection = PhotoCollection(collection: assetCollection) guard !hideIfEmpty || photoCollection.contents().assetCount > 0 else { return } collections.append(photoCollection) } let processPHAssetCollections: ((fetchResult: PHFetchResult, hideIfEmpty: Bool)) -> Void = { arg in let (fetchResult, hideIfEmpty) = arg fetchResult.enumerateObjects { (assetCollection, _, _) in // We're already sorting albums by last-updated. "Recently Added" is mostly redundant guard assetCollection.assetCollectionSubtype != .smartAlbumRecentlyAdded else { return } // undocumented constant let kRecentlyDeletedAlbumSubtype = PHAssetCollectionSubtype(rawValue: 1000000201) guard assetCollection.assetCollectionSubtype != kRecentlyDeletedAlbumSubtype else { return } processPHCollection((collection: assetCollection, hideIfEmpty: hideIfEmpty)) } } let processPHCollections: ((fetchResult: PHFetchResult, hideIfEmpty: Bool)) -> Void = { arg in let (fetchResult, hideIfEmpty) = arg for index in 0..