diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index fb8c7f684..c0a7a72a6 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -154,6 +154,9 @@ 3491D9A121022DB7001EF5A1 /* CDSSigningCertificateTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 3491D9A021022DB7001EF5A1 /* CDSSigningCertificateTest.m */; }; 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496744C2076768700080B5F /* OWSMessageBubbleView.m */; }; 3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496744E2076ACCE00080B5F /* LongTextViewController.swift */; }; + 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34969559219B605E00DCFE74 /* ImagePickerController.swift */; }; + 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */; }; + 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */; }; 349EA07C2162AEA800F7B17F /* OWS111UDAttributesMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EA07B2162AEA700F7B17F /* OWS111UDAttributesMigration.swift */; }; 34A55F3720485465002CC6DE /* OWS2FARegistrationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34A55F3520485464002CC6DE /* OWS2FARegistrationViewController.m */; }; 34A8B3512190A40E00218A25 /* MediaAlbumCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumCellView.swift */; }; @@ -434,7 +437,6 @@ 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 */; }; @@ -807,6 +809,9 @@ 3496744B2076768600080B5F /* OWSMessageBubbleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageBubbleView.h; sourceTree = ""; }; 3496744C2076768700080B5F /* OWSMessageBubbleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageBubbleView.m; sourceTree = ""; }; 3496744E2076ACCE00080B5F /* LongTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LongTextViewController.swift; sourceTree = ""; }; + 34969559219B605E00DCFE74 /* ImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; + 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCollectionPickerController.swift; sourceTree = ""; }; + 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoLibrary.swift; sourceTree = ""; }; 349EA07B2162AEA700F7B17F /* OWS111UDAttributesMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWS111UDAttributesMigration.swift; sourceTree = ""; }; 34A55F3520485464002CC6DE /* OWS2FARegistrationViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWS2FARegistrationViewController.m; sourceTree = ""; }; 34A55F3620485464002CC6DE /* OWS2FARegistrationViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWS2FARegistrationViewController.h; sourceTree = ""; }; @@ -1137,7 +1142,6 @@ 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 = ""; }; 4C1D2333218B692800A0598F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = translations/ko.lproj/Localizable.strings; sourceTree = ""; }; 4C1D2334218B6A1100A0598F /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = translations/az.lproj/Localizable.strings; sourceTree = ""; }; @@ -1735,13 +1739,25 @@ path = mocks; sourceTree = ""; }; + 34969558219B605E00DCFE74 /* PhotoLibrary */ = { + isa = PBXGroup; + children = ( + 34969559219B605E00DCFE74 /* ImagePickerController.swift */, + 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */, + 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, + ); + path = PhotoLibrary; + sourceTree = ""; + }; 34B3F8331E8DF1700035BE1A /* ViewControllers */ = { isa = PBXGroup; children = ( + 452B998F20A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift */, 340FC87A204DAC8C007AEB0F /* AppSettings */, 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */, 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, 34B3F83B1E8DF1700035BE1A /* CallViewController.swift */, + 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */, 348BB25C20A0C5530047AEC2 /* ContactShareViewHelper.swift */, 34B3F83E1E8DF1700035BE1A /* ContactsPicker.swift */, 34E88D252098C5AE00A608F4 /* ContactViewController.swift */, @@ -1770,6 +1786,7 @@ 45D2AC01204885170033C692 /* OWS2FAReminderViewController.swift */, 345BC30A2047030600257B7C /* OWS2FASettingsViewController.h */, 345BC30B2047030600257B7C /* OWS2FASettingsViewController.m */, + 34969558219B605E00DCFE74 /* PhotoLibrary */, 34CE88E51F2FB9A10098030F /* ProfileViewController.h */, 34CE88E61F2FB9A10098030F /* ProfileViewController.m */, 340FC875204DAC8C007AEB0F /* Registration */, @@ -1778,9 +1795,6 @@ 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */, 340FC897204DAC8D007AEB0F /* ThreadSettings */, 34D1F0BE1F8EC1760066283D /* Utils */, - 452B998F20A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift */, - 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */, - 4C1885CF218D0EA800B67051 /* ImagePickerController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -3387,6 +3401,7 @@ 34B3F8931E8DF1710035BE1A /* SignalsNavigationController.m in Sources */, 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */, 45B27B862037FFB400A539DF /* DebugUIFileBrowser.swift in Sources */, + 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */, 34CE88E71F2FB9A10098030F /* ProfileViewController.m in Sources */, 3403B95D20EA9527001A1F44 /* OWSContactShareButtonsView.m in Sources */, 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, @@ -3396,6 +3411,7 @@ 34D1F0AE1F867BFC0066283D /* OWSMessageCell.m in Sources */, 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */, + 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, 45D231771DC7E8F10034FA89 /* SessionResetJob.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, 452037D11EE84975004E4CDF /* DebugUISessionState.m in Sources */, @@ -3461,7 +3477,6 @@ 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 */, @@ -3480,6 +3495,7 @@ 4CA5F793211E1F06008C2708 /* Toast.swift in Sources */, 3488F9362191CC4000E524CC /* ConversationMediaView.swift in Sources */, 45F32C242057297A00A300D5 /* MessageDetailViewController.swift in Sources */, + 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, 34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */, 457F671B20746193000EABCD /* QuotedReplyPreview.swift in Sources */, 34DBF004206BD5A500025978 /* OWSBubbleView.m in Sources */, diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/Contents.json b/Signal/Images.xcassets/navbar_disclosure_down.imageset/Contents.json new file mode 100644 index 000000000..3cfcd92ea --- /dev/null +++ b/Signal/Images.xcassets/navbar_disclosure_down.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "navbar_disclosure_down_small@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "navbar_disclosure_down_small@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "navbar_disclosure_down_small@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@1x.png b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@1x.png new file mode 100644 index 000000000..fe04a6e29 Binary files /dev/null and b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@1x.png differ diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@2x.png b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@2x.png new file mode 100644 index 000000000..9e8d59312 Binary files /dev/null and b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@2x.png differ diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@3x.png b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@3x.png new file mode 100644 index 000000000..a8422b949 Binary files /dev/null and b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@3x.png differ diff --git a/Signal/Images.xcassets/navbar_disclosure_up.imageset/Contents.json b/Signal/Images.xcassets/navbar_disclosure_up.imageset/Contents.json new file mode 100644 index 000000000..d6a2a9467 --- /dev/null +++ b/Signal/Images.xcassets/navbar_disclosure_up.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "navbar_disclosure_up_small@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "navbar_disclosure_up_small@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "navbar_disclosure_up_small@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@1x.png b/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@1x.png new file mode 100644 index 000000000..444faf579 Binary files /dev/null and b/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@1x.png differ diff --git a/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@2x.png b/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@2x.png new file mode 100644 index 000000000..877020318 Binary files /dev/null and b/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@2x.png differ diff --git a/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@3x.png b/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@3x.png new file mode 100644 index 000000000..cc4936c9e Binary files /dev/null and b/Signal/Images.xcassets/navbar_disclosure_up.imageset/navbar_disclosure_up_small@3x.png differ diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIFileBrowser.swift b/Signal/src/ViewControllers/DebugUI/DebugUIFileBrowser.swift index 46f7ecf36..706a67a23 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIFileBrowser.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIFileBrowser.swift @@ -15,7 +15,7 @@ @objc init(fileURL: URL) { self.fileURL = fileURL - super.init(nibName: nil, bundle: nil) + super.init() self.contents = buildContents() } diff --git a/Signal/src/ViewControllers/ImagePickerController.swift b/Signal/src/ViewControllers/PhotoLibrary/ImagePickerController.swift similarity index 52% rename from Signal/src/ViewControllers/ImagePickerController.swift rename to Signal/src/ViewControllers/PhotoLibrary/ImagePickerController.swift index 460c5fcc1..87240a01b 100644 --- a/Signal/src/ViewControllers/ImagePickerController.swift +++ b/Signal/src/ViewControllers/PhotoLibrary/ImagePickerController.swift @@ -12,21 +12,25 @@ protocol ImagePickerControllerDelegate { } @objc(OWSImagePickerGridController) -class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate { +class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate, PhotoCollectionPickerDelegate { @objc weak var delegate: ImagePickerControllerDelegate? private let library: PhotoLibrary = PhotoLibrary() - private let libraryAlbum: PhotoLibraryAlbum - - var availableWidth: CGFloat = 0 + private var photoCollection: PhotoCollection + private var photoCollectionContents: PhotoCollectionContents + private let photoMediaSize = PhotoMediaSize() var collectionViewFlowLayout: UICollectionViewFlowLayout + private let titleLabel = UILabel() + init() { collectionViewFlowLayout = type(of: self).buildLayout() - libraryAlbum = library.albumForAllPhotos() + photoCollection = library.defaultPhotoCollection() + photoCollectionContents = photoCollection.contents() + super.init(collectionViewLayout: collectionViewFlowLayout) } @@ -39,9 +43,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat override func viewDidLoad() { super.viewDidLoad() - self.title = libraryAlbum.localizedTitle - - library.delegate = self + library.add(delegate: self) guard let collectionView = collectionView else { owsFailDebug("collectionView was unexpectedly nil") @@ -53,6 +55,23 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didPressCancel)) + + titleLabel.text = photoCollection.localizedTitle() + titleLabel.textColor = Theme.primaryColor + titleLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() + + let titleIconView = UIImageView() + titleIconView.tintColor = Theme.primaryColor + titleIconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate) + + let titleView = UIStackView(arrangedSubviews: [titleLabel, titleIconView]) + titleView.axis = .horizontal + titleView.alignment = .center + titleView.spacing = 5 + titleView.isUserInteractionEnabled = true + titleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped))) + navigationItem.titleView = titleView + let featureFlag_isMultiselectEnabled = true if featureFlag_isMultiselectEnabled { updateSelectButton() @@ -72,7 +91,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // 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) + photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale) } // MARK: Actions @@ -160,8 +179,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } - let assets: [PHAsset] = indexPaths.compactMap { return self.libraryAlbum.asset(at: $0.row) } - let promises = assets.map { return libraryAlbum.outgoingAttachment(for: $0) } + let assets: [PHAsset] = indexPaths.compactMap { return photoCollectionContents.asset(at: $0.row) } + let promises = assets.map { return photoCollectionContents.outgoingAttachment(for: $0) } when(fulfilled: promises).map { attachments in self.dismiss(animated: true) { self.delegate?.imagePicker(self, didPickImageAttachments: attachments) @@ -217,15 +236,39 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat collectionView?.reloadData() } + // MARK: PhotoCollectionPickerDelegate + + func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection) { + photoCollection = collection + photoCollectionContents = photoCollection.contents() + + titleLabel.text = photoCollection.localizedTitle() + + collectionView?.reloadData() + } + + // MARK: - Event Handlers + + @objc func titleTapped(sender: UIGestureRecognizer) { + guard sender.state == .recognized else { + return + } + let view = PhotoCollectionPickerController(library: library, + lastPhotoCollection: photoCollection, + collectionDelegate: self) + let nav = UINavigationController(rootViewController: view) + self.present(nav, animated: true, completion: nil) + } + // MARK: UICollectionView override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if isInBatchSelectMode { updateDoneButton() } else { - let asset = libraryAlbum.asset(at: indexPath.row) + let asset = photoCollectionContents.asset(at: indexPath.row) firstly { - libraryAlbum.outgoingAttachment(for: asset) + photoCollectionContents.outgoingAttachment(for: asset) }.map { attachment in self.dismiss(animated: true) { self.delegate?.imagePicker(self, didPickImageAttachments: [attachment]) @@ -243,7 +286,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return libraryAlbum.count + return photoCollectionContents.count } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { @@ -251,183 +294,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat owsFail("cell was unexpectedly nil") } - let mediaItem = libraryAlbum.mediaItem(at: indexPath.item) - cell.configure(item: mediaItem) + let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) + cell.configure(item: assetItem) 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, _, _ 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 - - _ = imageManager.requestExportSession(forVideo: asset, options: nil, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, _ 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 - 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/PhotoLibrary/PhotoCollectionPickerController.swift b/Signal/src/ViewControllers/PhotoLibrary/PhotoCollectionPickerController.swift new file mode 100644 index 000000000..e9c49bf34 --- /dev/null +++ b/Signal/src/ViewControllers/PhotoLibrary/PhotoCollectionPickerController.swift @@ -0,0 +1,146 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation +import Photos +import PromiseKit + +protocol PhotoCollectionPickerDelegate: class { + func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection) +} + +class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDelegate { + + private weak var collectionDelegate: PhotoCollectionPickerDelegate? + + private let library: PhotoLibrary + private let lastPhotoCollection: PhotoCollection + private var photoCollections: PhotoCollections + + required init(library: PhotoLibrary, + lastPhotoCollection: PhotoCollection, + collectionDelegate: PhotoCollectionPickerDelegate) { + self.library = library + self.lastPhotoCollection = lastPhotoCollection + self.photoCollections = library.allPhotoCollections() + self.collectionDelegate = collectionDelegate + super.init() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + let titleLabel = UILabel() + titleLabel.text = lastPhotoCollection.localizedTitle() + titleLabel.textColor = Theme.primaryColor + titleLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() + + let titleIconView = UIImageView() + titleIconView.tintColor = Theme.primaryColor + titleIconView.image = UIImage(named: "navbar_disclosure_up")?.withRenderingMode(.alwaysTemplate) + + let titleView = UIStackView(arrangedSubviews: [titleLabel, titleIconView]) + titleView.axis = .horizontal + titleView.alignment = .center + titleView.spacing = 5 + titleView.isUserInteractionEnabled = true + titleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped))) + navigationItem.titleView = titleView + + library.add(delegate: self) + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(didPressCancel)) + + updateContents() + } + + private func updateContents() { + photoCollections = library.allPhotoCollections() + + let section = OWSTableSection() + let count = photoCollections.count + for index in 0.. UITableViewCell in + let cell = OWSTableItem.newCell() + + let imageView = UIImageView() + let kImageSize = 50 + imageView.autoSetDimensions(to: CGSize(width: kImageSize, height: kImageSize)) + + let contents = collection.contents() + if contents.count > 0 { + let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize)) + let assetItem = contents.assetItem(at: 0, photoMediaSize: photoMediaSize) + imageView.image = assetItem.asyncThumbnail { [weak imageView] image in + guard let strongImageView = imageView else { + return + } + guard let image = image else { + return + } + strongImageView.image = image + } + } + + let titleLabel = UILabel() + titleLabel.text = collection.localizedTitle() + titleLabel.font = UIFont.ows_regularFont(withSize: 18) + titleLabel.textColor = Theme.primaryColor + + let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 10 + + cell.contentView.addSubview(stackView) + stackView.ows_autoPinToSuperviewMargins() + + return cell + }, + customRowHeight: UITableViewAutomaticDimension, + actionBlock: { [weak self] in + guard let strongSelf = self else { return } + strongSelf.didSelectCollection(collection: collection) + })) + } + let contents = OWSTableContents() + contents.addSection(section) + self.contents = contents + } + + // MARK: Actions + + @objc + func didPressCancel(sender: UIBarButtonItem) { + self.dismiss(animated: true) + } + + func didSelectCollection(collection: PhotoCollection) { + collectionDelegate?.photoCollectionPicker(self, didPickCollection: collection) + + self.dismiss(animated: true) + } + + @objc func titleTapped(sender: UIGestureRecognizer) { + guard sender.state == .recognized else { + return + } + self.dismiss(animated: true) + } + + // MARK: PhotoLibraryDelegate + + func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { + updateContents() + } +} diff --git a/Signal/src/ViewControllers/PhotoLibrary/PhotoLibrary.swift b/Signal/src/ViewControllers/PhotoLibrary/PhotoLibrary.swift new file mode 100644 index 000000000..390fdf59f --- /dev/null +++ b/Signal/src/ViewControllers/PhotoLibrary/PhotoLibrary.swift @@ -0,0 +1,288 @@ +// +// Copyright (c) 2018 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 album: PhotoCollectionContents + let photoMediaSize: PhotoMediaSize + + init(asset: PHAsset, album: PhotoCollectionContents, photoMediaSize: PhotoMediaSize) { + self.asset = asset + self.album = album + 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? { + album.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in + completion(image) + } + return nil + } +} + +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 + } + + var count: Int { + return fetchResult.count + } + + private let imageManager = PHCachingImageManager() + + func asset(at index: Int) -> PHAsset { + return fetchResult.object(at: index) + } + + func assetItem(at index: Int, photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem { + let mediaAsset = asset(at: index) + return PhotoPickerAssetItem(asset: mediaAsset, album: 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 + _ = imageManager.requestImageData(for: asset, options: nil) { imageData, dataUTI, _, _ 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 + + _ = imageManager.requestExportSession(forVideo: asset, options: nil, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, _ 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 + 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 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()) + } +} + +class PhotoCollections { + let collections: [PhotoCollection] + + init(collections: [PhotoCollection]) { + self.collections = collections + } + + var count: Int { + return collections.count + } + + func collection(at index: Int) -> PhotoCollection { + return collections[index] + } +} + +class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { + final class WeakDelegate { + weak var delegate: PhotoLibraryDelegate? + init(_ value: PhotoLibraryDelegate) { + delegate = value + } + } + var delegates = [WeakDelegate]() + + public func add(delegate: PhotoLibraryDelegate) { + delegates.append(WeakDelegate(delegate)) + } + + var assetCollection: PHAssetCollection! + + func photoLibraryDidChange(_ changeInstance: PHChange) { + DispatchQueue.main.async { + for weakDelegate in self.delegates { + weakDelegate.delegate?.photoLibraryDidChange(self) + } + } + } + + override init() { + super.init() + PHPhotoLibrary.shared().register(self) + } + + deinit { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + + func defaultPhotoCollection() -> PhotoCollection { + guard let photoCollection = allPhotoCollections().collections.first else { + owsFail("Could not locate Camera Roll.") + } + return photoCollection + } + + func allPhotoCollections() -> PhotoCollections { + var collections = [PhotoCollection]() + var collectionIds = Set() + + let processPHCollection: (PHCollection) -> Void = { (collection) in + // 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) + // Hide empty collections. + guard photoCollection.contents().count > 0 else { + return + } + collections.append(photoCollection) + } + let processPHAssetCollections: (PHFetchResult) -> Void = { (fetchResult) in + for index in 0..) -> Void = { (fetchResult) in + for index in 0..