From 4c5d46e8f89a512832d6d304fad9208e63f93a96 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 2 Nov 2018 18:08:19 -0600 Subject: [PATCH] Custom photo picker, respects theme/call banner - share GridViewCell - Multiple image selection, with feature flag, cant currently approve multiple --- Signal.xcodeproj/project.pbxproj | 8 + .../ConversationViewController.m | 81 +--- .../ImagePickerController.swift | 433 ++++++++++++++++++ .../MediaTileViewController.swift | 143 +----- Signal/src/views/PhotoGridViewCell.swift | 152 ++++++ .../translations/en.lproj/Localizable.strings | 3 + .../attachments/SignalAttachment.swift | 4 +- 7 files changed, 637 insertions(+), 187 deletions(-) create mode 100644 Signal/src/ViewControllers/ImagePickerController.swift create mode 100644 Signal/src/views/PhotoGridViewCell.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index dd1a1c598..cb77ae5a2 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -430,6 +430,8 @@ 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */; }; 4C11AA5020FD59C700351FBD /* MessageStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C11AA4F20FD59C700351FBD /* MessageStatusView.swift */; }; 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */; }; + 4C1885D0218D0EA800B67051 /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1885CF218D0EA800B67051 /* ImagePickerController.swift */; }; + 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */; }; 4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; }; 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */; }; 4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C23A5F1215C4ADE00534937 /* SheetViewController.swift */; }; @@ -1125,6 +1127,8 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HapticFeedback.swift; path = UserInterface/HapticFeedback.swift; sourceTree = ""; }; 4C11AA4F20FD59C700351FBD /* MessageStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStatusView.swift; sourceTree = ""; }; 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; + 4C1885CF218D0EA800B67051 /* ImagePickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; + 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridViewCell.swift; sourceTree = ""; }; 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = ""; }; 4C23A5F1215C4ADE00534937 /* SheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetViewController.swift; sourceTree = ""; }; 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTableViewCell.swift; sourceTree = ""; }; @@ -1762,6 +1766,7 @@ 34D1F0BE1F8EC1760066283D /* Utils */, 452B998F20A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift */, 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */, + 4C1885CF218D0EA800B67051 /* ImagePickerController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -2248,6 +2253,7 @@ 450D19121F85236600970622 /* RemoteVideoView.m */, 4CA5F792211E1F06008C2708 /* Toast.swift */, 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, + 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */, ); name = Views; path = views; @@ -3306,6 +3312,7 @@ 34DBF007206C3CB200025978 /* OWSBubbleShapeView.m in Sources */, 34D1F0BA1F8800D90066283D /* OWSAudioMessageView.m in Sources */, 34D8C02B1ED3685800188D7C /* DebugUIContacts.m in Sources */, + 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 45C9DEB81DF4E35A0065CA84 /* WebRTCCallMessageHandler.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, 34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */, @@ -3423,6 +3430,7 @@ 45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */, 340FC8BB204DAC8D007AEB0F /* OWSAddToContactViewController.m in Sources */, 340FC8C0204DB7D2007AEB0F /* OWSBackupExportJob.m in Sources */, + 4C1885D0218D0EA800B67051 /* ImagePickerController.swift in Sources */, 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, 340FC8A7204DAC8D007AEB0F /* RegistrationViewController.m in Sources */, 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index cfcd61cc4..ac8d0d71b 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -125,6 +125,7 @@ typedef enum : NSUInteger { UIDocumentMenuDelegate, UIDocumentPickerDelegate, UIImagePickerControllerDelegate, + OWSImagePickerControllerDelegate, UINavigationControllerDelegate, UITextViewDelegate, ConversationCollectionViewDelegate, @@ -2650,14 +2651,14 @@ typedef enum : NSUInteger { OWSLogWarn(@"Media Library permission denied."); return; } - - UIImagePickerController *picker = [UIImagePickerController new]; - picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + + OWSImagePickerGridController *picker = [OWSImagePickerGridController new]; picker.delegate = self; - picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; - + + OWSNavigationController *pickerModal = [[OWSNavigationController alloc] initWithRootViewController:picker]; + [self dismissKeyBoard]; - [self presentViewController:picker animated:YES completion:nil]; + [self presentViewController:pickerModal animated:YES completion:nil]; }]; } @@ -2677,6 +2678,14 @@ typedef enum : NSUInteger { self.view.frame = frame; } +- (void)imagePicker:(OWSImagePickerGridController *)imagePicker + didPickImageAttachments:(NSArray *)attachments +{ + // TODO support approving multiple attachments. + SignalAttachment *firstAttachment = attachments.firstObject; + [self tryToSendAttachmentIfApproved:firstAttachment]; +} + /* * Fetching data from UIImagePickerController */ @@ -2718,7 +2727,7 @@ typedef enum : NSUInteger { NSString *mediaType = info[UIImagePickerControllerMediaType]; if ([mediaType isEqualToString:(__bridge NSString *)kUTTypeMovie]) { - // Video picked from library or captured with camera + // Video captured with camera NSURL *videoURL = info[UIImagePickerControllerMediaURL]; [self dismissViewControllerAnimated:YES @@ -2756,57 +2765,8 @@ typedef enum : NSUInteger { } }]; } else { - // Non-Video image picked from library - - // To avoid re-encoding GIF and PNG's as JPEG we have to get the raw data of - // the selected item vs. using the UIImagePickerControllerOriginalImage - NSURL *assetURL = info[UIImagePickerControllerReferenceURL]; - PHAsset *asset = [[PHAsset fetchAssetsWithALAssetURLs:@[ assetURL ] options:nil] lastObject]; - if (!asset) { - return failedToPickAttachment(nil); - } - - // Images chosen from the "attach document" UI should be sent as originals; - // images chosen from the "attach media" UI should be resized to "medium" size; - TSImageQuality imageQuality = (self.isPickingMediaAsDocument ? TSImageQualityOriginal : TSImageQualityMedium); - - PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; - options.synchronous = YES; // We're only fetching one asset. - options.networkAccessAllowed = YES; // iCloud OK - options.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; // Don't need quick/dirty version - [[PHImageManager defaultManager] - requestImageDataForAsset:asset - options:options - resultHandler:^(NSData *_Nullable imageData, - NSString *_Nullable dataUTI, - UIImageOrientation orientation, - NSDictionary *_Nullable assetInfo) { - - NSError *assetFetchingError = assetInfo[PHImageErrorKey]; - if (assetFetchingError || !imageData) { - return failedToPickAttachment(assetFetchingError); - } - OWSAssertIsOnMainThread(); - - DataSource *_Nullable dataSource = - [DataSourceValue dataSourceWithData:imageData utiType:dataUTI]; - [dataSource setSourceFilename:filename]; - SignalAttachment *attachment = [SignalAttachment attachmentWithDataSource:dataSource - dataUTI:dataUTI - imageQuality:imageQuality]; - [self dismissViewControllerAnimated:YES - completion:^{ - OWSAssertIsOnMainThread(); - if (!attachment || [attachment hasError]) { - OWSLogWarn(@"Invalid attachment: %@.", - attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - failedToPickAttachment(nil); - } else { - [self tryToSendAttachmentIfApproved:attachment]; - } - }]; - }]; + OWSFailDebug( + @"Only use UIImagePicker for camera/video capture. Picking media from UIImagePicker is not supported. "); } } @@ -2878,9 +2838,8 @@ typedef enum : NSUInteger { VideoCompressionResult *compressionResult = [SignalAttachment compressVideoAsMp4WithDataSource:dataSource dataUTI:(NSString *)kUTTypeMPEG4]; - [compressionResult.attachmentPromise retainUntilComplete]; - compressionResult.attachmentPromise.then(^(SignalAttachment *attachment) { + [compressionResult.attachmentPromise.then(^(SignalAttachment *attachment) { OWSAssertIsOnMainThread(); OWSAssertDebug([attachment isKindOfClass:[SignalAttachment class]]); @@ -2897,7 +2856,7 @@ typedef enum : NSUInteger { [self tryToSendAttachmentIfApproved:attachment skipApprovalDialog:skipApprovalDialog]; } }]; - }); + }) retainUntilComplete]; }]; } diff --git a/Signal/src/ViewControllers/ImagePickerController.swift b/Signal/src/ViewControllers/ImagePickerController.swift new file mode 100644 index 000000000..899aad824 --- /dev/null +++ b/Signal/src/ViewControllers/ImagePickerController.swift @@ -0,0 +1,433 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation +import Photos +import PromiseKit + +@objc(OWSImagePickerControllerDelegate) +protocol ImagePickerControllerDelegate { + func imagePicker(_ imagePicker: ImagePickerGridController, didPickImageAttachments attachments: [SignalAttachment]) +} + +@objc(OWSImagePickerGridController) +class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate { + + @objc + weak var delegate: ImagePickerControllerDelegate? + + private let library: PhotoLibrary = PhotoLibrary() + private let libraryAlbum: PhotoLibraryAlbum + + var availableWidth: CGFloat = 0 + + var collectionViewFlowLayout: UICollectionViewFlowLayout + + init() { + collectionViewFlowLayout = type(of: self).buildLayout() + libraryAlbum = library.albumForAllPhotos() + super.init(collectionViewLayout: collectionViewFlowLayout) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.title = libraryAlbum.localizedTitle + + library.delegate = self + + guard let collectionView = collectionView else { + owsFailDebug("collectionView was unexpectedly nil") + return + } + + collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(didPressCancel)) + let featureFlag_isMultiselectEnabled = true + if featureFlag_isMultiselectEnabled { + updateSelectButton() + } + + collectionView.backgroundColor = Theme.backgroundColor + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + updateLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Determine the size of the thumbnails to request + let scale = UIScreen.main.scale + let cellSize = collectionViewFlowLayout.itemSize + libraryAlbum.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale) + } + + // MARK: Actions + + @objc + func didPressCancel(sender: UIBarButtonItem) { + self.dismiss(animated: true) + } + + // MARK: Layout + + static let kInterItemSpacing: CGFloat = 2 + private class func buildLayout() -> UICollectionViewFlowLayout { + let layout = UICollectionViewFlowLayout() + + if #available(iOS 11, *) { + layout.sectionInsetReference = .fromSafeArea + } + layout.minimumInteritemSpacing = kInterItemSpacing + layout.minimumLineSpacing = kInterItemSpacing + layout.sectionHeadersPinToVisibleBounds = true + + return layout + } + + func updateLayout() { + let containerWidth: CGFloat + if #available(iOS 11.0, *) { + containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width + } else { + containerWidth = self.view.frame.size.width + } + + let kItemsPerPortraitRow = 4 + let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) + let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow) + + let itemCount = round(containerWidth / approxItemWidth) + let spaceWidth = (itemCount + 1) * type(of: self).kInterItemSpacing + let availableWidth = containerWidth - spaceWidth + + let itemWidth = floor(availableWidth / CGFloat(itemCount)) + let newItemSize = CGSize(width: itemWidth, height: itemWidth) + + if (newItemSize != collectionViewFlowLayout.itemSize) { + collectionViewFlowLayout.itemSize = newItemSize + collectionViewFlowLayout.invalidateLayout() + } + } + + // MARK: Batch Selection + + lazy var doneButton: UIBarButtonItem = { + return UIBarButtonItem(barButtonSystemItem: .done, + target: self, + action: #selector(didPressDone)) + }() + + lazy var selectButton: UIBarButtonItem = { + return UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"), + style: .plain, + target: self, + action: #selector(didTapSelect)) + }() + + var isInBatchSelectMode = false { + didSet { + collectionView!.allowsMultipleSelection = isInBatchSelectMode + updateSelectButton() + updateDoneButton() + } + } + + @objc + func didPressDone(_ sender: Any) { + Logger.debug("") + + guard let collectionView = self.collectionView else { + owsFailDebug("collectionView was unexpectedly nil") + return + } + + guard let indexPaths = collectionView.indexPathsForSelectedItems else { + owsFailDebug("indexPaths was unexpectedly nil") + return + } + + let assets: [PHAsset] = indexPaths.compactMap { return self.libraryAlbum.asset(at: $0.row) } + let promises = assets.map { return libraryAlbum.outgoingAttachment(for: $0) } + when(fulfilled: promises).map { attachments in + self.dismiss(animated: true) { + self.delegate?.imagePicker(self, didPickImageAttachments: attachments) + } + }.retainUntilComplete() + } + + func updateDoneButton() { + guard let collectionView = self.collectionView else { + owsFailDebug("collectionView was unexpectedly nil") + return + } + + if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 { + self.doneButton.isEnabled = true + } else { + self.doneButton.isEnabled = false + } + } + + func updateSelectButton() { + navigationItem.rightBarButtonItem = isInBatchSelectMode ? doneButton : selectButton + } + + @objc + func didTapSelect(_ sender: Any) { + isInBatchSelectMode = true + + // disabled until at least one item is selected + self.doneButton.isEnabled = false + } + + @objc + func didCancelSelect(_ sender: Any) { + endSelectMode() + } + + func endSelectMode() { + isInBatchSelectMode = false + + guard let collectionView = self.collectionView else { + owsFailDebug("collectionView was unexpectedly nil") + return + } + + // deselect any selected + collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)} + } + + // MARK: PhotoLibraryDelegate + + func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { + collectionView?.reloadData() + } + + // MARK: UICollectionView + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if isInBatchSelectMode { + updateDoneButton() + } else { + let asset = libraryAlbum.asset(at: indexPath.row) + firstly { + libraryAlbum.outgoingAttachment(for: asset) + }.map { attachment in + self.dismiss(animated: true) { + self.delegate?.imagePicker(self, didPickImageAttachments: [attachment]) + } + }.retainUntilComplete() + } + } + + public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + Logger.debug("") + + if isInBatchSelectMode { + updateDoneButton() + } + } + + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return libraryAlbum.count + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else { + owsFail("cell was unexpectedly nil") + } + + let mediaItem = libraryAlbum.mediaItem(at: indexPath.item) + cell.configure(item: mediaItem) + return cell + } + +} + +protocol PhotoLibraryDelegate: class { + func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) +} + +class ImagePickerGridItem: PhotoGridItem { + + let asset: PHAsset + let album: PhotoLibraryAlbum + + init(asset: PHAsset, album: PhotoLibraryAlbum) { + self.asset = asset + self.album = album + } + + // MARK: PhotoGridItem + + var type: PhotoGridItemType { + if asset.mediaType == .video { + return .video + } + + // TODO show GIF badge? + + return .photo + } + + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { + album.requestThumbnail(for: self.asset) { image, _ in + completion(image) + } + return nil + } +} + +class PhotoLibraryAlbum { + + let fetchResult: PHFetchResult + let localizedTitle: String? + var thumbnailSize: CGSize = .zero + + enum PhotoLibraryError: Error { + case assertionError(_ description: String) + case unsupportedMediaType + + } + + init(fetchResult: PHFetchResult, localizedTitle: String?) { + self.fetchResult = fetchResult + self.localizedTitle = localizedTitle + } + + var count: Int { + return fetchResult.count + } + + private let imageManager = PHCachingImageManager() + + func asset(at index: Int) -> PHAsset { + return fetchResult.object(at: index) + } + + func mediaItem(at index: Int) -> ImagePickerGridItem { + let mediaAsset = asset(at: index) + return ImagePickerGridItem(asset: mediaAsset, album: self) + } + + // MARK: ImageManager + + func requestThumbnail(for asset: PHAsset, 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 + _ = imageManager.requestImageData(for: asset, options: nil) { imageData, dataUTI, orientation, info in + guard let imageData = imageData else { + resolver.reject(PhotoLibraryError.assertionError("imageData was unexpectedly nil")) + return + } + + guard let dataUTI = dataUTI else { + resolver.reject(PhotoLibraryError.assertionError("dataUTI was unexpectedly nil")) + return + } + + guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else { + resolver.reject(PhotoLibraryError.assertionError("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 + + _ = imageManager.requestExportSession(forVideo: asset, options: nil, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, info in + + guard let exportSession = exportSession else { + resolver.reject(PhotoLibraryError.assertionError("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("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 + return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium) + } + case .video: + return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in + return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI) + } + default: + return Promise(error: PhotoLibraryError.unsupportedMediaType) + } + } +} + +class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { + weak var delegate: PhotoLibraryDelegate? + + var assetCollection: PHAssetCollection! + var availableWidth: CGFloat = 0 + + func photoLibraryDidChange(_ changeInstance: PHChange) { + DispatchQueue.main.async { + self.delegate?.photoLibraryDidChange(self) + } + } + + override init() { + super.init() + PHPhotoLibrary.shared().register(self) + } + + deinit { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + + func albumForAllPhotos() -> PhotoLibraryAlbum { + let allPhotosOptions = PHFetchOptions() + allPhotosOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] + let fetchResult = PHAsset.fetchAssets(with: allPhotosOptions) + + let title = NSLocalizedString("PHOTO_PICKER_DEFAULT_ALBUM", comment: "navbar title when viewing the default photo album, which includes all photos") + return PhotoLibraryAlbum(fetchResult: fetchResult, localizedTitle: title) + } +} diff --git a/Signal/src/ViewControllers/MediaTileViewController.swift b/Signal/src/ViewControllers/MediaTileViewController.swift index c2ee6e2f8..5a2b1e353 100644 --- a/Signal/src/ViewControllers/MediaTileViewController.swift +++ b/Signal/src/ViewControllers/MediaTileViewController.swift @@ -68,7 +68,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa collectionView.backgroundColor = Theme.backgroundColor - collectionView.register(MediaGalleryCell.self, forCellWithReuseIdentifier: MediaGalleryCell.reuseIdentifier) + collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) collectionView.register(MediaGallerySectionHeader.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: MediaGallerySectionHeader.reuseIdentifier) collectionView.register(MediaGalleryStaticHeader.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier) @@ -209,12 +209,12 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa override public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { Logger.debug("") - guard let galleryCell = self.collectionView(collectionView, cellForItemAt: indexPath) as? MediaGalleryCell else { + guard let gridCell = self.collectionView(collectionView, cellForItemAt: indexPath) as? PhotoGridViewCell else { owsFailDebug("galleryCell was unexpectedly nil") return } - guard let galleryItem = galleryCell.item else { + guard let galleryItem = (gridCell.item as? GalleryGridCellItem)?.galleryItem else { owsFailDebug("galleryItem was unexpectedly nil") return } @@ -223,7 +223,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa updateDeleteButton() } else { collectionView.deselectItem(at: indexPath, animated: true) - self.delegate?.mediaTileViewController(self, didTapView: galleryCell.imageView, mediaGalleryItem: galleryItem) + self.delegate?.mediaTileViewController(self, didTapView: gridCell.imageView, mediaGalleryItem: galleryItem) } } @@ -359,12 +359,13 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDa return defaultCell } - guard let cell = self.collectionView?.dequeueReusableCell(withReuseIdentifier: MediaGalleryCell.reuseIdentifier, for: indexPath) as? MediaGalleryCell else { + guard let cell = self.collectionView?.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else { owsFailDebug("unexpected cell for indexPath: \(indexPath)") return defaultCell } - cell.configure(item: galleryItem) + let gridCellItem = GalleryGridCellItem(galleryItem: galleryItem) + cell.configure(item: gridCellItem) return cell } @@ -878,130 +879,24 @@ private class MediaGalleryStaticHeader: UICollectionViewCell { } } -private class MediaGalleryCell: UICollectionViewCell { +class GalleryGridCellItem: PhotoGridItem { + let galleryItem: MediaGalleryItem - static let reuseIdentifier = "MediaGalleryCell" - - public let imageView: UIImageView - - private let contentTypeBadgeView: UIImageView - private let selectedBadgeView: UIImageView - - private let highlightedView: UIView - private let selectedView: UIView - - fileprivate var item: MediaGalleryItem? - - static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video") - static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif") - static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle") - - override var isSelected: Bool { - didSet { - self.selectedBadgeView.isHidden = !self.isSelected - self.selectedView.isHidden = !self.isSelected - } + init(galleryItem: MediaGalleryItem) { + self.galleryItem = galleryItem } - override var isHighlighted: Bool { - didSet { - self.highlightedView.isHidden = !self.isHighlighted - } - } - - override init(frame: CGRect) { - self.imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - - self.contentTypeBadgeView = UIImageView() - contentTypeBadgeView.isHidden = true - - self.selectedBadgeView = UIImageView() - selectedBadgeView.image = MediaGalleryCell.selectedBadgeImage - selectedBadgeView.isHidden = true - - self.highlightedView = UIView() - highlightedView.alpha = 0.2 - highlightedView.backgroundColor = Theme.primaryColor - highlightedView.isHidden = true - - self.selectedView = UIView() - selectedView.alpha = 0.3 - selectedView.backgroundColor = Theme.backgroundColor - selectedView.isHidden = true - - super.init(frame: frame) - - self.clipsToBounds = true - - self.contentView.addSubview(imageView) - self.contentView.addSubview(contentTypeBadgeView) - self.contentView.addSubview(highlightedView) - self.contentView.addSubview(selectedView) - self.contentView.addSubview(selectedBadgeView) - - imageView.autoPinEdgesToSuperviewEdges() - highlightedView.autoPinEdgesToSuperviewEdges() - selectedView.autoPinEdgesToSuperviewEdges() - - // Note assets were rendered to match exactly. We don't want to re-size with - // content mode lest they become less legible. - let kContentTypeBadgeSize = CGSize(width: 18, height: 12) - contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 3) - contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3) - contentTypeBadgeView.autoSetDimensions(to: kContentTypeBadgeSize) - - let kSelectedBadgeSize = CGSize(width: 31, height: 31) - selectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0) - selectedBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 0) - selectedBadgeView.autoSetDimensions(to: kSelectedBadgeSize) - } - - @available(*, unavailable, message: "Unimplemented") - required public init?(coder aDecoder: NSCoder) { - notImplemented() - } - - public func configure(item: MediaGalleryItem) { - self.item = item - if let image = item.thumbnailImage(async: { - [weak self] (image) in - guard let strongSelf = self else { - return - } - guard strongSelf.item == item else { - return - } - strongSelf.imageView.image = image - strongSelf.imageView.backgroundColor = UIColor.clear - }) { - self.imageView.image = image - self.imageView.backgroundColor = UIColor.clear + var type: PhotoGridItemType { + if galleryItem.isVideo { + return .video + } else if galleryItem.isAnimated { + return .animated } else { - // TODO: Show a placeholder? - self.imageView.backgroundColor = Theme.offBackgroundColor - } - - if item.isVideo { - self.contentTypeBadgeView.isHidden = false - self.contentTypeBadgeView.image = MediaGalleryCell.videoBadgeImage - } else if item.isAnimated { - self.contentTypeBadgeView.isHidden = false - self.contentTypeBadgeView.image = MediaGalleryCell.animatedBadgeImage - } else { - assert(item.isImage) - self.contentTypeBadgeView.isHidden = true + return .photo } } - override public func prepareForReuse() { - super.prepareForReuse() - - self.item = nil - self.imageView.image = nil - self.contentTypeBadgeView.isHidden = true - self.highlightedView.isHidden = true - self.selectedView.isHidden = true - self.selectedBadgeView.isHidden = true + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { + return galleryItem.thumbnailImage(async: completion) } } diff --git a/Signal/src/views/PhotoGridViewCell.swift b/Signal/src/views/PhotoGridViewCell.swift new file mode 100644 index 000000000..2114a94dc --- /dev/null +++ b/Signal/src/views/PhotoGridViewCell.swift @@ -0,0 +1,152 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +public enum PhotoGridItemType { + case photo, animated, video +} + +public protocol PhotoGridItem: class { + var type: PhotoGridItemType { get } + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? +} + +public class PhotoGridViewCell: UICollectionViewCell { + + static let reuseIdentifier = "PhotoGridViewCell" + + public let imageView: UIImageView + + private let contentTypeBadgeView: UIImageView + private let selectedBadgeView: UIImageView + + private let highlightedView: UIView + private let selectedView: UIView + + var item: PhotoGridItem? + + private static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video") + private static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif") + private static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle") + + override public var isSelected: Bool { + didSet { + self.selectedBadgeView.isHidden = !self.isSelected + self.selectedView.isHidden = !self.isSelected + } + } + + override public var isHighlighted: Bool { + didSet { + self.highlightedView.isHidden = !self.isHighlighted + } + } + + override init(frame: CGRect) { + self.imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + + self.contentTypeBadgeView = UIImageView() + contentTypeBadgeView.isHidden = true + + self.selectedBadgeView = UIImageView() + selectedBadgeView.image = PhotoGridViewCell.selectedBadgeImage + selectedBadgeView.isHidden = true + + self.highlightedView = UIView() + highlightedView.alpha = 0.2 + highlightedView.backgroundColor = Theme.primaryColor + highlightedView.isHidden = true + + self.selectedView = UIView() + selectedView.alpha = 0.3 + selectedView.backgroundColor = Theme.backgroundColor + selectedView.isHidden = true + + super.init(frame: frame) + + self.clipsToBounds = true + + self.contentView.addSubview(imageView) + self.contentView.addSubview(contentTypeBadgeView) + self.contentView.addSubview(highlightedView) + self.contentView.addSubview(selectedView) + self.contentView.addSubview(selectedBadgeView) + + imageView.autoPinEdgesToSuperviewEdges() + highlightedView.autoPinEdgesToSuperviewEdges() + selectedView.autoPinEdgesToSuperviewEdges() + + // Note assets were rendered to match exactly. We don't want to re-size with + // content mode lest they become less legible. + let kContentTypeBadgeSize = CGSize(width: 18, height: 12) + contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 3) + contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3) + contentTypeBadgeView.autoSetDimensions(to: kContentTypeBadgeSize) + + let kSelectedBadgeSize = CGSize(width: 31, height: 31) + selectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0) + selectedBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 0) + selectedBadgeView.autoSetDimensions(to: kSelectedBadgeSize) + } + + @available(*, unavailable, message: "Unimplemented") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + var image: UIImage? { + get { return imageView.image } + set { + imageView.image = newValue + imageView.backgroundColor = newValue == nil ? Theme.offBackgroundColor : .clear + } + } + + var contentTypeBadgeImage: UIImage? { + get { return contentTypeBadgeView.image } + set { + contentTypeBadgeView.image = newValue + contentTypeBadgeView.isHidden = newValue == nil + } + } + + public func configure(item: PhotoGridItem) { + self.item = item + + self.image = item.asyncThumbnail { image in + guard let currentItem = self.item else { + return + } + + guard currentItem === item else { + return + } + + if image == nil { + Logger.debug("image == nil") + } + self.image = image + } + + switch item.type { + case .video: + self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage + case .animated: + self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage + case .photo: + self.contentTypeBadgeImage = nil + } + } + + override public func prepareForReuse() { + super.prepareForReuse() + + self.item = nil + self.imageView.image = nil + self.contentTypeBadgeView.isHidden = true + self.highlightedView.isHidden = true + self.selectedView.isHidden = true + self.selectedBadgeView.isHidden = true + } +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 11ce3a134..025ebab82 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1502,6 +1502,9 @@ /* Label for 'Work FAX' phone numbers. */ "PHONE_NUMBER_TYPE_WORK_FAX" = "Work Fax"; +/* navbar title when viewing the default photo album, which includes all photos */ +"PHOTO_PICKER_DEFAULT_ALBUM" = "All Photos"; + /* Accessibility label for button to start media playback */ "PLAY_BUTTON_ACCESSABILITY_LABEL" = "Play Media"; diff --git a/SignalMessaging/attachments/SignalAttachment.swift b/SignalMessaging/attachments/SignalAttachment.swift index 0953efe5b..4430491f5 100644 --- a/SignalMessaging/attachments/SignalAttachment.swift +++ b/SignalMessaging/attachments/SignalAttachment.swift @@ -243,7 +243,7 @@ public class SignalAttachment: NSObject { public var outgoingAttachmentInfo: OutgoingAttachmentInfo { return OutgoingAttachmentInfo(dataSource: dataSource, contentType: mimeType, sourceFilename: filenameOrDefault) } - + @objc public func image() -> UIImage? { if let cachedImage = cachedImage { @@ -1106,7 +1106,7 @@ public class SignalAttachment: NSObject { // MARK: Attachments - // Factory method for attachments of any kind. + // Factory method for non-image Attachments. // // NOTE: The attachment returned by this method may not be valid. // Check the attachment's error property.