From 4242001828a643df0429755b17e9598f49c9a0ef Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 28 Sep 2017 23:30:33 -0400 Subject: [PATCH] Sketch out the GIF picker. // FREEBIE --- .../GifPicker/GifPickerCell.swift | 659 ++---------------- .../GifPicker/GifPickerViewController.swift | 12 +- Signal/src/network/GifManager.swift | 282 +++++++- 3 files changed, 355 insertions(+), 598 deletions(-) diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift b/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift index fb34145bc..8b9d6c479 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift @@ -9,10 +9,27 @@ class GifPickerCell: UICollectionViewCell { // MARK: Properties - var imageInfo: GiphyImageInfo? + var imageInfo: GiphyImageInfo? { + didSet { + AssertIsOnMainThread() + + ensureLoad() + } + } + + var shouldLoad = false { + didSet { + AssertIsOnMainThread() + + ensureLoad() + } + } + + var assetRequest: GiphyAssetRequest? + var asset: GiphyAsset? + + // MARK: Initializers -// // MARK: Initializers -// @available(*, unavailable, message:"use other constructor instead.") required init?(coder aDecoder: NSCoder) { // self.searchBar = UISearchBar() @@ -39,594 +56,52 @@ class GifPickerCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() + + imageInfo = nil + shouldLoad = false + asset = nil + assetRequest?.cancel() + assetRequest = nil + + // TODO: + self.backgroundColor = UIColor.red + } + + private func clearAssetRequest() { + assetRequest?.cancel() + assetRequest = nil + } + + private func ensureLoad() { + guard shouldLoad else { + clearAssetRequest() + return + } + guard let imageInfo = imageInfo else { + clearAssetRequest() + return + } + guard self.assetRequest == nil else { + return + } + guard let rendition = imageInfo.pickGifRendition() else { + Logger.warn("\(TAG) could not pick rendition") + clearAssetRequest() + return + } + Logger.verbose("\(TAG) picked rendition: \(rendition.name)") + + assetRequest = GifManager.sharedInstance.downloadAssetAsync(rendition:rendition, + success: { [weak self] asset in + guard let strongSelf = self else { return } + strongSelf.clearAssetRequest() + strongSelf.asset = asset + // TODO: + strongSelf.backgroundColor = UIColor.blue + }, + failure: { [weak self] in + guard let strongSelf = self else { return } + strongSelf.clearAssetRequest() + }) } -// -// // MARK: View Lifecycle -// -// override func viewDidLoad() { -// super.viewDidLoad() -// -// view.backgroundColor = UIColor.black -// -// self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem:.stop, -// target:self, -// action:#selector(donePressed)) -// self.navigationItem.title = NSLocalizedString("GIF_PICKER_VIEW_TITLE", -// comment: "Title for the 'gif picker' dialog.") -// -// createViews() -// } -// -// // MARK: Views -// -// private func createViews() { -// -// view.backgroundColor = UIColor.black -// -// // Search -//// searchBar.searchBarStyle = .minimal -// searchBar.searchBarStyle = .default -// searchBar.delegate = self -// searchBar.placeholder = NSLocalizedString("GIF_VIEW_SEARCH_PLACEHOLDER_TEXT", -// comment:"Placeholder text for the search field in gif view") -//// searchBar.backgroundColor = UIColor(white:0.6, alpha:1.0) -//// searchBar.backgroundColor = UIColor.white -//// searchBar.backgroundColor = UIColor.black -//// searchBar.barTintColor = UIColor.red -// searchBar.isTranslucent = false -//// searchBar.backgroundColor = UIColor.white -// searchBar.backgroundImage = UIImage(color:UIColor.clear) -// searchBar.barTintColor = UIColor.black -// searchBar.tintColor = UIColor.white -// self.view.addSubview(searchBar) -// searchBar.autoPinWidthToSuperview() -// searchBar.autoPin(toTopLayoutGuideOf: self, withInset:0) -// // [searchBar sizeToFit]; -// -// self.collectionView.delegate = self -// self.collectionView.dataSource = self -// self.collectionView.backgroundColor = UIColor.black -// self.view.addSubview(self.collectionView) -// self.collectionView.autoPinWidthToSuperview() -// self.collectionView.autoPinEdge(.top, to:.bottom, of:searchBar) -// self.collectionView.autoPin(toBottomLayoutGuideOf: self, withInset:0) -// -// let logoImage = UIImage(named:"giphy_logo") -// let logoImageView = UIImageView(image:logoImage) -// self.logoImageView = logoImageView -// self.view.addSubview(logoImageView) -// logoImageView.autoCenterInSuperview() -// -// self.updateContents() -// // [self updateTableContents]; -// } -// -// private func setContentVisible(_ isVisible:Bool) { -// self.collectionView.isHidden = !isVisible -// if let logoImageView = self.logoImageView { -// logoImageView.isHidden = isVisible -// } -// } -// -// private func updateContents() { -// if imageInfos.count < 1 { -// setContentVisible(false) -// } else { -// setContentVisible(true) -// } -// -// self.collectionView.collectionViewLayout.invalidateLayout() -// self.collectionView.reloadData() -// } -// -// // override func viewDidLoad() { -// // super.viewDidLoad() -// // -// // view.backgroundColor = UIColor.white -// // -// // self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem:.stop, -// // target:self, -// // action:#selector(donePressed)) -// // self.navigationItem.title = dialogTitle() -// // -// // createViews() -// // } -// // -// // private func dialogTitle() -> String { -// // guard let filename = formattedFileName() else { -// // return NSLocalizedString("ATTACHMENT_APPROVAL_DIALOG_TITLE", -// // comment: "Title for the 'attachment approval' dialog.") -// // } -// // return filename -// // } -// // -// // override func viewWillAppear(_ animated: Bool) { -// // super.viewWillAppear(animated) -// // -// // ViewControllerUtils.setAudioIgnoresHardwareMuteSwitch(true) -// // } -// // -// // override func viewWillDisappear(_ animated: Bool) { -// // super.viewWillDisappear(animated) -// // -// // ViewControllerUtils.setAudioIgnoresHardwareMuteSwitch(false) -// // } -// // -// // // MARK: - Create Views -// // -// // private func createViews() { -// // let previewTopMargin: CGFloat = 30 -// // let previewHMargin: CGFloat = 20 -// // -// // let attachmentPreviewView = UIView() -// // self.view.addSubview(attachmentPreviewView) -// // attachmentPreviewView.autoPinWidthToSuperview(withMargin:previewHMargin) -// // attachmentPreviewView.autoPin(toTopLayoutGuideOf: self, withInset:previewTopMargin) -// // -// // createButtonRow(attachmentPreviewView:attachmentPreviewView) -// // -// // if attachment.isAnimatedImage { -// // createAnimatedPreview(attachmentPreviewView:attachmentPreviewView) -// // } else if attachment.isImage { -// // createImagePreview(attachmentPreviewView:attachmentPreviewView) -// // } else if attachment.isVideo { -// // createVideoPreview(attachmentPreviewView:attachmentPreviewView) -// // } else if attachment.isAudio { -// // createAudioPreview(attachmentPreviewView:attachmentPreviewView) -// // } else { -// // createGenericPreview(attachmentPreviewView:attachmentPreviewView) -// // } -// // } -// // -// // private func wrapViewsInVerticalStack(subviews: [UIView]) -> UIView { -// // assert(subviews.count > 0) -// // -// // let stackView = UIView() -// // -// // var lastView: UIView? -// // for subview in subviews { -// // -// // stackView.addSubview(subview) -// // subview.autoHCenterInSuperview() -// // -// // if lastView == nil { -// // subview.autoPinEdge(toSuperviewEdge:.top) -// // } else { -// // subview.autoPinEdge(.top, to:.bottom, of:lastView!, withOffset:10) -// // } -// // -// // lastView = subview -// // } -// // -// // lastView?.autoPinEdge(toSuperviewEdge:.bottom) -// // -// // return stackView -// // } -// // -// // private func createAudioPreview(attachmentPreviewView: UIView) { -// // guard let dataUrl = attachment.dataUrl else { -// // createGenericPreview(attachmentPreviewView:attachmentPreviewView) -// // return -// // } -// // -// // audioPlayer = OWSAudioAttachmentPlayer(mediaUrl: dataUrl, delegate: self) -// // -// // var subviews = [UIView]() -// // -// // let audioPlayButton = UIButton() -// // self.audioPlayButton = audioPlayButton -// // setAudioIconToPlay() -// // audioPlayButton.imageView?.layer.minificationFilter = kCAFilterTrilinear -// // audioPlayButton.imageView?.layer.magnificationFilter = kCAFilterTrilinear -// // audioPlayButton.addTarget(self, action:#selector(audioPlayButtonPressed), for:.touchUpInside) -// // let buttonSize = createHeroViewSize() -// // audioPlayButton.autoSetDimension(.width, toSize:buttonSize) -// // audioPlayButton.autoSetDimension(.height, toSize:buttonSize) -// // subviews.append(audioPlayButton) -// // -// // let fileNameLabel = createFileNameLabel() -// // if let fileNameLabel = fileNameLabel { -// // subviews.append(fileNameLabel) -// // } -// // -// // let fileSizeLabel = createFileSizeLabel() -// // subviews.append(fileSizeLabel) -// // -// // let audioStatusLabel = createAudioStatusLabel() -// // self.audioStatusLabel = audioStatusLabel -// // updateAudioStatusLabel() -// // subviews.append(audioStatusLabel) -// // -// // let stackView = wrapViewsInVerticalStack(subviews:subviews) -// // attachmentPreviewView.addSubview(stackView) -// // fileNameLabel?.autoPinWidthToSuperview(withMargin: 32) -// // stackView.autoPinWidthToSuperview() -// // stackView.autoVCenterInSuperview() -// // } -// // -// // private func createAnimatedPreview(attachmentPreviewView: UIView) { -// // guard attachment.isValidImage else { -// // return -// // } -// // let data = attachment.data -// // // Use Flipboard FLAnimatedImage library to display gifs -// // guard let animatedImage = FLAnimatedImage(gifData:data) else { -// // createGenericPreview(attachmentPreviewView:attachmentPreviewView) -// // return -// // } -// // let animatedImageView = FLAnimatedImageView() -// // animatedImageView.animatedImage = animatedImage -// // animatedImageView.contentMode = .scaleAspectFit -// // attachmentPreviewView.addSubview(animatedImageView) -// // animatedImageView.autoPinWidthToSuperview() -// // animatedImageView.autoPinHeightToSuperview() -// // } -// // -// // private func createImagePreview(attachmentPreviewView: UIView) { -// // var image = attachment.image -// // if image == nil { -// // image = UIImage(data:attachment.data) -// // } -// // guard image != nil else { -// // createGenericPreview(attachmentPreviewView:attachmentPreviewView) -// // return -// // } -// // -// // let imageView = UIImageView(image:image) -// // imageView.layer.minificationFilter = kCAFilterTrilinear -// // imageView.layer.magnificationFilter = kCAFilterTrilinear -// // imageView.contentMode = .scaleAspectFit -// // attachmentPreviewView.addSubview(imageView) -// // imageView.autoPinWidthToSuperview() -// // imageView.autoPinHeightToSuperview() -// // } -// // -// // private func createVideoPreview(attachmentPreviewView: UIView) { -// // guard let dataUrl = attachment.dataUrl else { -// // createGenericPreview(attachmentPreviewView:attachmentPreviewView) -// // return -// // } -// // guard let videoPlayer = MPMoviePlayerController(contentURL:dataUrl) else { -// // createGenericPreview(attachmentPreviewView:attachmentPreviewView) -// // return -// // } -// // videoPlayer.prepareToPlay() -// // -// // videoPlayer.controlStyle = .default -// // videoPlayer.shouldAutoplay = false -// // -// // attachmentPreviewView.addSubview(videoPlayer.view) -// // self.videoPlayer = videoPlayer -// // videoPlayer.view.autoPinWidthToSuperview() -// // videoPlayer.view.autoPinHeightToSuperview() -// // } -// // -// // private func createGenericPreview(attachmentPreviewView: UIView) { -// // var subviews = [UIView]() -// // -// // let imageView = createHeroImageView(imageName: "file-thin-black-filled-large") -// // subviews.append(imageView) -// // -// // let fileNameLabel = createFileNameLabel() -// // if let fileNameLabel = fileNameLabel { -// // subviews.append(fileNameLabel) -// // } -// // -// // let fileSizeLabel = createFileSizeLabel() -// // subviews.append(fileSizeLabel) -// // -// // let stackView = wrapViewsInVerticalStack(subviews:subviews) -// // attachmentPreviewView.addSubview(stackView) -// // fileNameLabel?.autoPinWidthToSuperview(withMargin: 32) -// // stackView.autoPinWidthToSuperview() -// // stackView.autoVCenterInSuperview() -// // } -// // -// // private func createHeroViewSize() -> CGFloat { -// // return ScaleFromIPhone5To7Plus(175, 225) -// // } -// // -// // private func createHeroImageView(imageName: String) -> UIView { -// // let imageSize = createHeroViewSize() -// // let image = UIImage(named:imageName) -// // assert(image != nil) -// // let imageView = UIImageView(image:image) -// // imageView.layer.minificationFilter = kCAFilterTrilinear -// // imageView.layer.magnificationFilter = kCAFilterTrilinear -// // imageView.layer.shadowColor = UIColor.black.cgColor -// // let shadowScaling = 5.0 -// // imageView.layer.shadowRadius = CGFloat(2.0 * shadowScaling) -// // imageView.layer.shadowOpacity = 0.25 -// // imageView.layer.shadowOffset = CGSize(width: 0.75 * shadowScaling, height: 0.75 * shadowScaling) -// // imageView.autoSetDimension(.width, toSize:imageSize) -// // imageView.autoSetDimension(.height, toSize:imageSize) -// // -// // return imageView -// // } -// // -// // private func labelFont() -> UIFont { -// // return UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(18, 24)) -// // } -// // -// // private func formattedFileExtension() -> String? { -// // guard let fileExtension = attachment.fileExtension else { -// // return nil -// // } -// // -// // return String(format:NSLocalizedString("ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT", -// // comment: "Format string for file extension label in call interstitial view"), -// // fileExtension.uppercased()) -// // } -// // -// // private func formattedFileName() -> String? { -// // guard let sourceFilename = attachment.sourceFilename else { -// // return nil -// // } -// // let filename = sourceFilename.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) -// // guard filename.characters.count > 0 else { -// // return nil -// // } -// // return filename -// // } -// // -// // private func createFileNameLabel() -> UIView? { -// // let filename = formattedFileName() ?? formattedFileExtension() -// // -// // guard filename != nil else { -// // return nil -// // } -// // -// // let label = UILabel() -// // label.text = filename -// // label.textColor = UIColor.ows_materialBlue() -// // label.font = labelFont() -// // label.textAlignment = .center -// // label.lineBreakMode = .byTruncatingMiddle -// // return label -// // } -// // -// // private func createFileSizeLabel() -> UIView { -// // let label = UILabel() -// // let fileSize = attachment.dataLength -// // label.text = String(format:NSLocalizedString("ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT", -// // comment: "Format string for file size label in call interstitial view. Embeds: {{file size as 'N mb' or 'N kb'}}."), -// // ViewControllerUtils.formatFileSize(UInt(fileSize))) -// // -// // label.textColor = UIColor.ows_materialBlue() -// // label.font = labelFont() -// // label.textAlignment = .center -// // -// // return label -// // } -// // -// // private func createAudioStatusLabel() -> UILabel { -// // let label = UILabel() -// // label.textColor = UIColor.ows_materialBlue() -// // label.font = labelFont() -// // label.textAlignment = .center -// // -// // return label -// // } -// // -// // private func createButtonRow(attachmentPreviewView: UIView) { -// // let buttonTopMargin = ScaleFromIPhone5To7Plus(30, 40) -// // let buttonBottomMargin = ScaleFromIPhone5To7Plus(25, 40) -// // let buttonHSpacing = ScaleFromIPhone5To7Plus(20, 30) -// // -// // let buttonRow = UIView() -// // self.view.addSubview(buttonRow) -// // buttonRow.autoPinWidthToSuperview() -// // buttonRow.autoPinEdge(toSuperviewEdge:.bottom, withInset:buttonBottomMargin) -// // buttonRow.autoPinEdge(.top, to:.bottom, of:attachmentPreviewView, withOffset:buttonTopMargin) -// // -// // // We use this invisible subview to ensure that the buttons are centered -// // // horizontally. -// // let buttonSpacer = UIView() -// // buttonRow.addSubview(buttonSpacer) -// // // Vertical positioning of this view doesn't matter. -// // buttonSpacer.autoPinEdge(toSuperviewEdge:.top) -// // buttonSpacer.autoSetDimension(.width, toSize:buttonHSpacing) -// // buttonSpacer.autoHCenterInSuperview() -// // -// // let cancelButton = createButton(title: CommonStrings.cancelButton, -// // color : UIColor.ows_destructiveRed(), -// // action: #selector(cancelPressed)) -// // buttonRow.addSubview(cancelButton) -// // cancelButton.autoPinEdge(toSuperviewEdge:.top) -// // cancelButton.autoPinEdge(toSuperviewEdge:.bottom) -// // cancelButton.autoPinEdge(.right, to:.left, of:buttonSpacer) -// // -// // let sendButton = createButton(title: NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", -// // comment: "Label for 'send' button in the 'attachment approval' dialog."), -// // color : UIColor(rgbHex:0x2ecc71), -// // action: #selector(sendPressed)) -// // buttonRow.addSubview(sendButton) -// // sendButton.autoPinEdge(toSuperviewEdge:.top) -// // sendButton.autoPinEdge(toSuperviewEdge:.bottom) -// // sendButton.autoPinEdge(.left, to:.right, of:buttonSpacer) -// // } -// // -// // private func createButton(title: String, color: UIColor, action: Selector) -> UIView { -// // let buttonWidth = ScaleFromIPhone5To7Plus(110, 140) -// // let buttonHeight = ScaleFromIPhone5To7Plus(35, 45) -// // -// // return OWSFlatButton.button(title:title, -// // titleColor:UIColor.white, -// // backgroundColor:color, -// // width:buttonWidth, -// // height:buttonHeight, -// // target:target, -// // selector:action) -// // } -// // -// // // MARK: - Event Handlers -// // -// // func donePressed(sender: UIButton) { -// // dismiss(animated: true, completion:nil) -// // } -// // -// // func cancelPressed(sender: UIButton) { -// // dismiss(animated: true, completion:nil) -// // } -// // -// // func sendPressed(sender: UIButton) { -// // let successCompletion = self.successCompletion -// // dismiss(animated: true, completion: { -// // successCompletion?() -// // }) -// // } -// // -// // func audioPlayButtonPressed(sender: UIButton) { -// // audioPlayer?.togglePlayState() -// // } -// // -// // // MARK: - OWSAudioAttachmentPlayerDelegate -// // -// // public func isAudioPlaying() -> Bool { -// // return isAudioPlayingFlag -// // } -// // -// // public func setIsAudioPlaying(_ isAudioPlaying: Bool) { -// // isAudioPlayingFlag = isAudioPlaying -// // -// // updateAudioStatusLabel() -// // } -// // -// // public func isPaused() -> Bool { -// // return isAudioPaused -// // } -// // -// // public func setIsPaused(_ isPaused: Bool) { -// // isAudioPaused = isPaused -// // } -// // -// // public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { -// // audioProgressSeconds = progress -// // audioDurationSeconds = duration -// // -// // updateAudioStatusLabel() -// // } -// // -// // private func updateAudioStatusLabel() { -// // guard let audioStatusLabel = self.audioStatusLabel else { -// // owsFail("Missing audio status label") -// // return -// // } -// // -// // if isAudioPlayingFlag && audioProgressSeconds > 0 && audioDurationSeconds > 0 { -// // audioStatusLabel.text = String(format:"%@ / %@", -// // ViewControllerUtils.formatDurationSeconds(Int(round(self.audioProgressSeconds))), -// // ViewControllerUtils.formatDurationSeconds(Int(round(self.audioDurationSeconds)))) -// // } else { -// // audioStatusLabel.text = " " -// // } -// // } -// // -// // public func setAudioIconToPlay() { -// // let image = UIImage(named:"audio_play_black_large")?.withRenderingMode(.alwaysTemplate) -// // assert(image != nil) -// // audioPlayButton?.setImage(image, for:.normal) -// // audioPlayButton?.imageView?.tintColor = UIColor.ows_materialBlue() -// // } -// // -// // public func setAudioIconToPause() { -// // let image = UIImage(named:"audio_pause_black_large")?.withRenderingMode(.alwaysTemplate) -// // assert(image != nil) -// // audioPlayButton?.setImage(image, for:.normal) -// // audioPlayButton?.imageView?.tintColor = UIColor.ows_materialBlue() -// // } -// -// // MARK: - UICollectionViewDataSource -// -// override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { -// return imageInfos.count -// } -// -// // The cell that is returned must be retrieved from a call to -dequeueReusableCellWithReuseIdentifier:forIndexPath: -// override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { -// let cell = self.dequeueReusableCell(withReuseIdentifier identifier: String, for indexPath: IndexPath) -> UICollectionViewCell -// } -// -// -// // MARK: - UICollectionViewDelegate -// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didUnhighlightItemAt indexPath: IndexPath) -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool // called when the user taps on an already-selected item in multi-select mode -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) -//// -//// -//// @available(iOS 8.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) -//// -//// @available(iOS 8.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) -//// -//// -//// // These methods provide support for copy/paste actions on cells. -//// // All three should be implemented if any are. -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, shouldShowMenuForItemAt indexPath: IndexPath) -> Bool -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, canPerformAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -> Bool -//// -//// @available(iOS 6.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, performAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -//// -//// -//// // support for custom transition layout -//// @available(iOS 7.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, transitionLayoutForOldLayout fromLayout: UICollectionViewLayout, newLayout toLayout: UICollectionViewLayout) -> UICollectionViewTransitionLayout -//// -//// -//// // Focus -//// @available(iOS 9.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool -//// -//// @available(iOS 9.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, shouldUpdateFocusIn context: UICollectionViewFocusUpdateContext) -> Bool -//// -//// @available(iOS 9.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) -//// -//// @available(iOS 9.0, *) -//// optional public func indexPathForPreferredFocusedView(in collectionView: UICollectionView) -> IndexPath? -//// -//// -//// @available(iOS 9.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath -//// -//// -//// @available(iOS 9.0, *) -//// optional public func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint // customize the content offset to be applied during transition or update animations -////} -// -// // MARK: - Event Handlers -// -// func donePressed(sender: UIButton) { -// dismiss(animated: true, completion:nil) -// } } diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift index 66b16eb80..327968380 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift @@ -512,11 +512,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect } public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - + guard let cell = cell as? GifPickerCell else { + owsFail("\(TAG) unexpected cell.") + return + } + cell.shouldLoad = true } public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - + guard let cell = cell as? GifPickerCell else { + owsFail("\(TAG) unexpected cell.") + return + } + cell.shouldLoad = false } // MARK: - Event Handlers diff --git a/Signal/src/network/GifManager.swift b/Signal/src/network/GifManager.swift index 1691e54f5..1d3995090 100644 --- a/Signal/src/network/GifManager.swift +++ b/Signal/src/network/GifManager.swift @@ -3,6 +3,7 @@ // import Foundation +import ObjectiveC enum GiphyFormat { case gif, webp, mp4 @@ -44,9 +45,10 @@ enum GiphyFormat { self.originalRendition = originalRendition } + // TODO: let kMaxDimension = UInt(618) let kMinDimension = UInt(101) - let kMaxFileSize = SignalAttachment.kMaxFileSizeAnimatedImage + let kMaxFileSize = UInt(3 * 1024 * 1024) public func pickGifRendition() -> GiphyRendition? { var bestRendition: GiphyRendition? @@ -81,7 +83,68 @@ enum GiphyFormat { } } -@objc class GifManager: NSObject { +@objc class GiphyAssetRequest: NSObject { + static let TAG = "[GiphyAssetRequest]" + + let rendition: GiphyRendition + let success: ((GiphyAsset) -> Void) + let failure: (() -> Void) + var wasCancelled = false + var assetFilePath: String? + + init(rendition: GiphyRendition, + success:@escaping ((GiphyAsset) -> Void), + failure:@escaping (() -> Void) + ) { + self.rendition = rendition + self.success = success + self.failure = failure + } + + public func cancel() { + wasCancelled = true + } +} + +@objc class GiphyAsset: NSObject { + static let TAG = "[GiphyAsset]" + + let rendition: GiphyRendition + let filePath: String + + init(rendition: GiphyRendition, + filePath: String) { + self.rendition = rendition + self.filePath = filePath + } + + deinit { + let filePathCopy = filePath + DispatchQueue.global().async { + do { + let fileManager = FileManager.default + try fileManager.removeItem(atPath:filePathCopy) + } catch let error as NSError { + owsFail("\(GiphyAsset.TAG) file cleanup failed: \(filePathCopy), \(error)") + } + } + } +} + +private var URLSessionTask_GiphyAssetRequest: UInt8 = 0 + +extension URLSessionTask { + var assetRequest: GiphyAssetRequest { + get { + return objc_getAssociatedObject(self, &URLSessionTask_GiphyAssetRequest) as! GiphyAssetRequest + } + set { + objc_setAssociatedObject(self, &URLSessionTask_GiphyAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +@objc class GifManager: NSObject, URLSessionTaskDelegate, URLSessionDownloadDelegate { // MARK: - Properties @@ -89,6 +152,8 @@ enum GiphyFormat { static let sharedInstance = GifManager() + private let operationQueue = OperationQueue() + // Force usage as a singleton override private init() {} @@ -98,7 +163,7 @@ enum GiphyFormat { private let kGiphyBaseURL = "https://api.giphy.com/" - private func giphySessionManager() -> AFHTTPSessionManager? { + private func giphyAPISessionManager() -> AFHTTPSessionManager? { guard let baseUrl = NSURL(string:kGiphyBaseURL) else { Logger.error("\(GifManager.TAG) Invalid base URL.") return nil @@ -120,6 +185,33 @@ enum GiphyFormat { return sessionManager } + private func giphyDownloadSession() -> URLSession? { +// guard let baseUrl = NSURL(string:kGiphyBaseURL) else { +// Logger.error("\(GifManager.TAG) Invalid base URL.") +// return nil +// } + // TODO: Is this right? + let configuration = URLSessionConfiguration.ephemeral + // TODO: Is this right? + configuration.connectionProxyDictionary = [ + kCFProxyHostNameKey as String: "giphy-proxy-production.whispersystems.org", + kCFProxyPortNumberKey as String: "80", + kCFProxyTypeKey as String: kCFProxyTypeHTTPS + ] + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringCacheData + let session = URLSession(configuration:configuration, delegate:self, delegateQueue:operationQueue) + return session +// NSURLSession * session = [NSURLSession sessionWithConfiguration:configuration]; +// +// let sessionManager = AFHTTPSessionManager(baseURL:baseUrl as URL, +// sessionConfiguration:sessionConf) +// sessionManager.requestSerializer = AFJSONRequestSerializer() +// sessionManager.responseSerializer = AFJSONResponseSerializer() +// +// return sessionManager + } + // TODO: public func test() { search(query:"monkey", @@ -128,8 +220,10 @@ enum GiphyFormat { }) } + // MARK: Search + public func search(query: String, success: @escaping (([GiphyImageInfo]) -> Void), failure: @escaping (() -> Void)) { - guard let sessionManager = giphySessionManager() else { + guard let sessionManager = giphyAPISessionManager() else { Logger.error("\(GifManager.TAG) Couldn't create session manager.") failure() return @@ -169,6 +263,8 @@ enum GiphyFormat { }) } + // MARK: Parse API Responses + private func parseGiphyImages(responseJson:Any?) -> [GiphyImageInfo]? { guard let responseJson = responseJson else { Logger.error("\(GifManager.TAG) Missing response.") @@ -316,4 +412,182 @@ enum GiphyFormat { } return parsedValue } + + // MARK: Rendition Download + +// private static let serialQueue = DispatchQueue(label: "org.signal.gif.download") + + // TODO: Use a proper cache. + // TODO: Write to cache. + private var assetMap = [NSURL: GiphyAsset]() + // TODO: We could use a proper queue. + private var assetRequestQueue = [GiphyAssetRequest]() + private var isDownloading = false + + // The success and failure handlers are always called on main queue. + // The success and failure handlers may be called synchronously on cache hit. + public func downloadAssetAsync(rendition: GiphyRendition, + success:@escaping ((GiphyAsset) -> Void), + failure:@escaping (() -> Void)) -> GiphyAssetRequest? { + AssertIsOnMainThread() + + if let asset = assetMap[rendition.url] { + success(asset) + return nil + } + + let assetRequest = GiphyAssetRequest(rendition:rendition, + success : { asset in + DispatchQueue.main.async { + self.assetMap[rendition.url] = asset + success(asset) + self.isDownloading = false + self.downloadIfNecessary() + } + }, + failure : { + DispatchQueue.main.async { + failure() + self.isDownloading = false + self.downloadIfNecessary() + } + }) + assetRequestQueue.append(assetRequest) + downloadIfNecessary() + return assetRequest + } + + private func downloadIfNecessary() { + AssertIsOnMainThread() + + DispatchQueue.main.async { +// GifManager.serialQueue.async { + guard !self.isDownloading else { + return + } + guard self.assetRequestQueue.count > 0 else { + return + } + guard let assetRequest = self.assetRequestQueue.first else { + owsFail("\(GiphyAsset.TAG) could not pop asset requests") + return + } + self.assetRequestQueue.removeFirst() + guard !assetRequest.wasCancelled else { + DispatchQueue.main.async { + self.downloadIfNecessary() + } + return + } + self.isDownloading = true + + if let asset = self.assetMap[assetRequest.rendition.url] { + // Deferred cache hit, avoids re-downloading assets already in the + // asset cache. + assetRequest.success(asset) + return + } + + guard let downloadSession = self.giphyDownloadSession() else { + Logger.error("\(GifManager.TAG) Couldn't create session manager.") + assetRequest.failure() + return + } + + let task = downloadSession.downloadTask(with:assetRequest.rendition.url as URL) + task.assetRequest = assetRequest + task.resume() + } + } + + // MARK: URLSessionDataDelegate + + @nonobjc + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + completionHandler(.allow) + } + + // MARK: URLSessionTaskDelegate + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let assetRequest = task.assetRequest + guard !assetRequest.wasCancelled else { + task.cancel() + return + } + if let error = error { + Logger.error("\(GifManager.TAG) download failed with error: \(error)") + assetRequest.failure() + return + } + guard let httpResponse = task.response as? HTTPURLResponse else { + Logger.error("\(GifManager.TAG) missing or unexpected response: \(task.response)") + assetRequest.failure() + return + } + let statusCode = httpResponse.statusCode + guard statusCode >= 200 && statusCode < 400 else { + Logger.error("\(GifManager.TAG) response has invalid status code: \(statusCode)") + assetRequest.failure() + return + } + guard let assetFilePath = assetRequest.assetFilePath else { + Logger.error("\(GifManager.TAG) task is missing asset file") + assetRequest.failure() + return + } + Logger.verbose("\(GifManager.TAG) download succeeded: \(assetRequest.rendition.url)") + let asset = GiphyAsset(rendition: assetRequest.rendition, filePath : assetFilePath) + assetRequest.success(asset) + } + + // MARK: URLSessionDownloadDelegate + + private func fileExtension(forFormat format: GiphyFormat) -> String { + switch format { + case .gif: + return "gif" + case .webp: + return "webp" + case .mp4: + return "mp4" + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + let assetRequest = downloadTask.assetRequest + guard !assetRequest.wasCancelled else { + downloadTask.cancel() + return + } + + let dirPath = NSTemporaryDirectory() + let fileExtension = self.fileExtension(forFormat:assetRequest.rendition.format) + let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)! + let filePath = (dirPath as NSString).appendingPathComponent(fileName) + + do { + try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath:filePath)) + assetRequest.assetFilePath = filePath + } catch let error as NSError { + owsFail("\(GiphyAsset.TAG) file move failed from: \(location), to: \(filePath), \(error)") + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + let assetRequest = downloadTask.assetRequest + guard !assetRequest.wasCancelled else { + downloadTask.cancel() + return + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { + let assetRequest = downloadTask.assetRequest + guard !assetRequest.wasCancelled else { + downloadTask.cancel() + return + } + } }