session-ios/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewContr...

825 lines
29 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import AVFoundation
import MediaPlayer
import PromiseKit
import SessionUIKit
import CoreServices
import SessionMessagingKit
public protocol AttachmentApprovalViewControllerDelegate: AnyObject {
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didApproveAttachments attachments: [SignalAttachment],
forThreadId threadId: String,
messageText: String?
)
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController)
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didChangeMessageText newMessageText: String?
)
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didRemoveAttachment attachment: SignalAttachment
)
func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController)
}
// MARK: -
@objc
public enum AttachmentApprovalViewControllerMode: UInt {
case modal
case sharedNavigation
}
// MARK: -
public class AttachmentApprovalViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
public enum Mode: UInt {
case modal
case sharedNavigation
}
// MARK: - Properties
private let mode: Mode
private let threadId: String
private let isAddMoreVisible: Bool
public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate?
public var isEditingCaptions = false {
didSet { updateContents() }
}
let attachmentItemCollection: AttachmentItemCollection
var attachmentItems: [SignalAttachmentItem] {
return attachmentItemCollection.attachmentItems
}
var attachments: [SignalAttachment] {
return attachmentItems.map { (attachmentItem) in
autoreleasepool {
return self.processedAttachment(forAttachmentItem: attachmentItem)
}
}
}
public var pageViewControllers: [AttachmentPrepViewController]? {
return viewControllers?.compactMap { $0 as? AttachmentPrepViewController }
}
public var currentPageViewController: AttachmentPrepViewController? {
return pageViewControllers?.first
}
var currentItem: SignalAttachmentItem? {
get { return currentPageViewController?.attachmentItem }
set { setCurrentItem(newValue, direction: .forward, animated: false) }
}
private var cachedPages: [SignalAttachmentItem: AttachmentPrepViewController] = [:]
public var shouldHideControls: Bool {
guard let pageViewController: AttachmentPrepViewController = pageViewControllers?.first else {
return false
}
return pageViewController.shouldHideControls
}
override public var inputAccessoryView: UIView? {
bottomToolView.layoutIfNeeded()
return bottomToolView
}
override public var canBecomeFirstResponder: Bool {
return !shouldHideControls
}
public var messageText: String? {
get { return bottomToolView.attachmentTextToolbar.messageText }
set { bottomToolView.attachmentTextToolbar.messageText = newValue }
}
// MARK: - Initializers
@available(*, unavailable, message:"use attachment: constructor instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
required public init(
mode: Mode,
threadId: String,
attachments: [SignalAttachment]
) {
assert(attachments.count > 0)
self.mode = mode
self.threadId = threadId
let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0 )}
self.isAddMoreVisible = (mode == .sharedNavigation)
self.attachmentItemCollection = AttachmentItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible)
super.init(
transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: [
.interPageSpacing: kSpacingBetweenItems
]
)
self.dataSource = self
self.delegate = self
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
public class func wrappedInNavController(
threadId: String,
attachments: [SignalAttachment],
approvalDelegate: AttachmentApprovalViewControllerDelegate
) -> OWSNavigationController {
let vc = AttachmentApprovalViewController(mode: .modal, threadId: threadId, attachments: attachments)
vc.approvalDelegate = approvalDelegate
let navController = OWSNavigationController(rootViewController: vc)
navController.ows_prefersStatusBarHidden = true
return navController
}
// MARK: - UI
private let kSpacingBetweenItems: CGFloat = 20
public override var prefersStatusBarHidden: Bool { return true }
private lazy var bottomToolView: AttachmentApprovalInputAccessoryView = {
let bottomToolView = AttachmentApprovalInputAccessoryView()
bottomToolView.delegate = self
bottomToolView.attachmentTextToolbar.attachmentTextToolbarDelegate = self
bottomToolView.galleryRailView.delegate = self
return bottomToolView
}()
private var galleryRailView: GalleryRailView { return bottomToolView.galleryRailView }
private lazy var touchInterceptorView: UIView = {
let view: UIView = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
let tapGesture = UITapGestureRecognizer(
target: self,
action: #selector(didTapTouchInterceptorView(gesture:))
)
view.addGestureRecognizer(tapGesture)
return view
}()
private lazy var pagerScrollView: UIScrollView? = {
// This is kind of a hack. Since we don't have first class access to the superview's `scrollView`
// we traverse the view hierarchy until we find it.
let pagerScrollView = view.subviews.first { $0 is UIScrollView } as? UIScrollView
assert(pagerScrollView != nil)
return pagerScrollView
}()
// MARK: - Lifecycle
override public func viewDidLoad() {
super.viewDidLoad()
self.view.themeBackgroundColor = .backgroundSecondary
// Avoid an unpleasant "bounce" which doesn't make sense in the context of a single item.
pagerScrollView?.isScrollEnabled = (attachmentItems.count > 1)
guard let firstItem = attachmentItems.first else {
owsFailDebug("firstItem was unexpectedly nil")
return
}
self.setCurrentItem(firstItem, direction: .forward, animated: false)
view.addSubview(touchInterceptorView)
// layout immediately to avoid animating the layout process during the transition
UIView.performWithoutAnimation {
self.currentPageViewController?.view.layoutIfNeeded()
}
// If the first item is just text, or is a URL and LinkPreviews are disabled
// then just fill the 'message' box with it
if firstItem.attachment.isText || (firstItem.attachment.isUrl && LinkPreview.previewUrl(for: firstItem.attachment.text()) == nil) {
bottomToolView.attachmentTextToolbar.messageText = firstItem.attachment.text()
}
setupLayout()
}
override public func viewWillAppear(_ animated: Bool) {
Logger.debug("")
super.viewWillAppear(animated)
updateContents()
}
override public func viewDidAppear(_ animated: Bool) {
Logger.debug("")
super.viewDidAppear(animated)
updateContents()
}
override public func viewWillDisappear(_ animated: Bool) {
Logger.debug("")
super.viewWillDisappear(animated)
}
// MARK: - Layout
private func setupLayout() {
touchInterceptorView.autoPinEdgesToSuperviewEdges()
}
// MARK: - Notifications
@objc func didBecomeActive() {
AssertIsOnMainThread()
updateContents()
}
// MARK: - Contents
private func updateContents() {
updateNavigationBar()
updateInputAccessory()
touchInterceptorView.isHidden = !isEditingCaptions
}
// MARK: - Input Accessory
public func updateInputAccessory() {
var currentPageViewController: AttachmentPrepViewController?
if pageViewControllers?.count == 1 {
currentPageViewController = pageViewControllers?.first
}
let currentAttachmentItem: SignalAttachmentItem? = currentPageViewController?.attachmentItem
let hasPresentedView = (self.presentedViewController != nil)
let isToolbarFirstResponder = bottomToolView.hasFirstResponder
if !shouldHideControls, !isFirstResponder, !hasPresentedView, !isToolbarFirstResponder {
becomeFirstResponder()
}
bottomToolView.update(
isEditingCaptions: isEditingCaptions,
currentAttachmentItem: currentAttachmentItem,
shouldHideControls: shouldHideControls
)
}
// MARK: - Navigation Bar
public func updateNavigationBar() {
guard !shouldHideControls else {
self.navigationItem.leftBarButtonItem = nil
self.navigationItem.rightBarButtonItem = nil
return
}
guard !isEditingCaptions else {
// Hide all navigation bar items while the caption view is open.
self.navigationItem.leftBarButtonItem = UIBarButtonItem(
//"Title for 'caption' mode of the attachment approval view."
title: "ATTACHMENT_APPROVAL_CAPTION_TITLE".localized(),
style: .plain,
target: nil,
action: nil
)
let doneButton = navigationBarButton(
imageName: "image_editor_checkmark_full",
selector: #selector(didTapCaptionDone(sender:))
)
let navigationBarItems = [doneButton]
updateNavigationBar(navigationBarItems: navigationBarItems)
return
}
var navigationBarItems = [UIView]()
if viewControllers?.count == 1, let firstViewController: AttachmentPrepViewController = viewControllers?.first as? AttachmentPrepViewController {
navigationBarItems = firstViewController.navigationBarItems()
// Show the caption UI if there's more than one attachment
// OR if the attachment already has a caption.
if attachmentItemCollection.count > 0, (firstViewController.attachmentItem.captionText?.count ?? 0) > 0 {
let captionButton = navigationBarButton(
imageName: "image_editor_caption",
selector: #selector(didTapCaption(sender:))
)
navigationBarItems.append(captionButton)
}
}
updateNavigationBar(navigationBarItems: navigationBarItems)
if mode != .sharedNavigation {
// Mimic a UIBarButtonItem of type .cancel, but with a shadow.
let cancelButton = OWSButton(title: CommonStrings.cancelButton) { [weak self] in
self?.cancelPressed()
}
cancelButton.titleLabel?.font = .systemFont(ofSize: 17.0)
cancelButton.setThemeTitleColor(.textPrimary, for: .normal)
cancelButton.setThemeTitleColor(.textSecondary, for: .highlighted)
cancelButton.sizeToFit()
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: cancelButton)
}
else {
// Mimic a conventional back button, but with a shadow.
let isRTL = CurrentAppContext().isRTL
let imageName = (isRTL ? "NavBarBackRTL" : "NavBarBack")
let backButton = OWSButton(imageName: imageName, tintColor: .textPrimary) { [weak self] in
self?.navigationController?.popViewController(animated: true)
}
// Nudge closer to the left edge to match default back button item.
let kExtraLeftPadding: CGFloat = isRTL ? +0 : -8
// Give some extra hit area to the back button. This is a little smaller
// than the default back button, but makes sense for our left aligned title
// view in the MessagesViewController
let kExtraRightPadding: CGFloat = isRTL ? -0 : +10
// Extra hit area above/below
let kExtraHeightPadding: CGFloat = 4
// Matching the default backbutton placement is tricky.
// We can't just adjust the imageEdgeInsets on a UIBarButtonItem directly,
// so we adjust the imageEdgeInsets on a UIButton, then wrap that
// in a UIBarButtonItem.
backButton.contentHorizontalAlignment = .left
// Default back button is 1.5 pixel lower than our extracted image.
let kTopInsetPadding: CGFloat = 1.5
backButton.imageEdgeInsets = UIEdgeInsets(
top: kTopInsetPadding,
left: kExtraLeftPadding,
bottom: 0,
right: 0
)
var backImageSize = CGSize.zero
if let backImage = UIImage(named: imageName) {
backImageSize = backImage.size
}
else {
owsFailDebug("Missing backImage.")
}
backButton.frame = CGRect(
origin: .zero,
size: CGSize(
width: backImageSize.width + kExtraRightPadding,
height: backImageSize.height + kExtraHeightPadding
)
)
// Note: using a custom leftBarButtonItem breaks the interactive pop gesture.
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
}
}
// MARK: - View Helpers
func remove(attachmentItem: SignalAttachmentItem) {
if attachmentItem.isEqual(to: currentItem) {
if let nextItem = attachmentItemCollection.itemAfter(item: attachmentItem) {
setCurrentItem(nextItem, direction: .forward, animated: true)
}
else if let prevItem = attachmentItemCollection.itemBefore(item: attachmentItem) {
setCurrentItem(prevItem, direction: .reverse, animated: true)
}
else {
owsFailDebug("removing last item shouldn't be possible because rail should not be visible")
return
}
}
self.attachmentItemCollection.remove(item: attachmentItem)
self.approvalDelegate?.attachmentApproval(self, didRemoveAttachment: attachmentItem.attachment)
self.updateMediaRail()
}
// MARK: - UIPageViewControllerDelegate
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
Logger.debug("")
assert(pendingViewControllers.count == 1)
pendingViewControllers.forEach { viewController in
guard let pendingPage = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return
}
// use compact scale when keyboard is popped.
let scale: AttachmentPrepViewController.AttachmentViewScale = self.isFirstResponder ? .fullsize : .compact
pendingPage.setAttachmentViewScale(scale, animated: false)
}
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) {
Logger.debug("")
assert(previousViewControllers.count == 1)
previousViewControllers.forEach { viewController in
guard let previousPage = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return
}
if transitionCompleted {
previousPage.zoomOut(animated: false)
updateMediaRail()
}
}
}
// MARK: - UIPageViewControllerDataSource
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let currentViewController = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return nil
}
let currentItem = currentViewController.attachmentItem
guard let previousItem = attachmentItem(before: currentItem) else { return nil }
guard let previousPage: AttachmentPrepViewController = buildPage(item: previousItem) else {
return nil
}
return previousPage
}
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
Logger.debug("")
guard let currentViewController = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return nil
}
let currentItem = currentViewController.attachmentItem
guard let nextItem = attachmentItem(after: currentItem) else { return nil }
guard let nextPage: AttachmentPrepViewController = buildPage(item: nextItem) else {
return nil
}
return nextPage
}
@objc
public override func setViewControllers(_ viewControllers: [UIViewController]?, direction: UIPageViewController.NavigationDirection, animated: Bool, completion: ((Bool) -> Void)? = nil) {
super.setViewControllers(
viewControllers,
direction: direction,
animated: animated
) { [weak self] finished in
completion?(finished)
self?.updateContents()
}
}
private func buildPage(item: SignalAttachmentItem) -> AttachmentPrepViewController? {
if let cachedPage = cachedPages[item] {
Logger.debug("cache hit.")
return cachedPage
}
Logger.debug("cache miss.")
let viewController = AttachmentPrepViewController(attachmentItem: item)
viewController.prepDelegate = self
cachedPages[item] = viewController
return viewController
}
private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) {
guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else {
owsFailDebug("unexpectedly unable to build new page")
return
}
page.loadViewIfNeeded()
self.setViewControllers([page], direction: direction, animated: isAnimated, completion: nil)
updateMediaRail()
}
func updateMediaRail() {
guard let currentItem = self.currentItem else {
owsFailDebug("currentItem was unexpectedly nil")
return
}
let cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView = { [weak self] railItem in
switch railItem {
case is AddMoreRailItem:
return GalleryRailCellView()
case is SignalAttachmentItem:
let cell = ApprovalRailCellView()
cell.approvalRailCellDelegate = self
return cell
default:
owsFailDebug("unexpted rail item type: \(railItem)")
return GalleryRailCellView()
}
}
galleryRailView.configureCellViews(
album: (attachmentItemCollection.attachmentItems as [GalleryRailItem])
.appending(attachmentItemCollection.isAddMoreVisible ?
AddMoreRailItem() :
nil
),
focusedItem: currentItem,
cellViewBuilder: cellViewBuilder
)
if isAddMoreVisible {
galleryRailView.isHidden = false
}
else if attachmentItemCollection.attachmentItems.count > 1 {
galleryRailView.isHidden = false
}
else {
galleryRailView.isHidden = true
}
}
// For any attachments edited with the image editor, returns a
// new SignalAttachment that reflects those changes. Otherwise,
// returns the original attachment.
//
// If any errors occurs in the export process, we fail over to
// sending the original attachment. This seems better than trying
// to involve the user in resolving the issue.
func processedAttachment(forAttachmentItem attachmentItem: SignalAttachmentItem) -> SignalAttachment {
guard let imageEditorModel = attachmentItem.imageEditorModel else {
// Image was not edited.
return attachmentItem.attachment
}
guard imageEditorModel.isDirty() else {
// Image editor has no changes.
return attachmentItem.attachment
}
guard let dstImage = ImageEditorCanvasView.renderForOutput(model: imageEditorModel, transform: imageEditorModel.currentTransform()) else {
owsFailDebug("Could not render for output.")
return attachmentItem.attachment
}
var dataUTI = kUTTypeImage as String
let maybeDstData: Data? = {
let isLossy: Bool = (
attachmentItem.attachment.mimeType.caseInsensitiveCompare(OWSMimeTypeImageJpeg) == .orderedSame
)
if isLossy {
dataUTI = kUTTypeJPEG as String
return dstImage.jpegData(compressionQuality: 0.9)
}
else {
dataUTI = kUTTypePNG as String
return dstImage.pngData()
}
}()
guard let dstData: Data = maybeDstData else {
owsFailDebug("Could not export for output.")
return attachmentItem.attachment
}
guard let dataSource = DataSourceValue.dataSource(with: dstData, utiType: dataUTI) else {
owsFailDebug("Could not prepare data source for output.")
return attachmentItem.attachment
}
// Rewrite the filename's extension to reflect the output file format.
var filename: String? = attachmentItem.attachment.sourceFilename
if let sourceFilename = attachmentItem.attachment.sourceFilename {
if let fileExtension: String = MIMETypeUtil.fileExtension(forUTIType: dataUTI) {
filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension)
}
}
dataSource.sourceFilename = filename
let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
if let attachmentError = dstAttachment.error {
owsFailDebug("Could not prepare attachment for output: \(attachmentError).")
return attachmentItem.attachment
}
// Preserve caption text.
dstAttachment.captionText = attachmentItem.captionText
return dstAttachment
}
func attachmentItem(before currentItem: SignalAttachmentItem) -> SignalAttachmentItem? {
guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return nil
}
let index: Int = attachmentItems.index(before: currentIndex)
guard let previousItem = attachmentItems[safe: index] else {
// already at first item
return nil
}
return previousItem
}
func attachmentItem(after currentItem: SignalAttachmentItem) -> SignalAttachmentItem? {
guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return nil
}
let index: Int = attachmentItems.index(after: currentIndex)
guard let nextItem = attachmentItems[safe: index] else {
// already at last item
return nil
}
return nextItem
}
// MARK: - Event Handlers
@objc
func didTapTouchInterceptorView(gesture: UITapGestureRecognizer) {
Logger.info("")
isEditingCaptions = false
}
private func cancelPressed() {
self.approvalDelegate?.attachmentApprovalDidCancel(self)
}
@objc func didTapCaption(sender: UIButton) {
Logger.verbose("")
isEditingCaptions = true
}
@objc func didTapCaptionDone(sender: UIButton) {
Logger.verbose("")
isEditingCaptions = false
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate {
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {}
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {}
func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) {
// Toolbar flickers in and out if there are errors
// and remains visible momentarily after share extension is dismissed.
// It's easiest to just hide it at this point since we're done with it.
currentPageViewController?.shouldAllowAttachmentViewResizing = false
attachmentTextToolbar.isUserInteractionEnabled = false
attachmentTextToolbar.isHidden = true
approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText)
}
func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) {
approvalDelegate?.attachmentApproval(self, didChangeMessageText: attachmentTextToolbar.messageText)
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate {
func prepViewControllerUpdateNavigationBar() {
updateNavigationBar()
}
func prepViewControllerUpdateControls() {
updateInputAccessory()
}
}
// MARK: GalleryRail
extension SignalAttachmentItem: GalleryRailItem {
func buildRailItemView() -> UIView {
let imageView = UIImageView()
imageView.image = getThumbnailImage()
imageView.themeBackgroundColor = .backgroundSecondary
imageView.contentMode = .scaleAspectFill
return imageView
}
func isEqual(to other: GalleryRailItem?) -> Bool {
guard let otherAttachmentItem: SignalAttachmentItem = other as? SignalAttachmentItem else { return false }
return (self.attachment == otherAttachmentItem.attachment)
}
}
// MARK: -
extension AttachmentApprovalViewController: GalleryRailViewDelegate {
public func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) {
if imageRailItem is AddMoreRailItem {
self.approvalDelegate?.attachmentApprovalDidTapAddMore(self)
return
}
guard let targetItem = imageRailItem as? SignalAttachmentItem else {
owsFailDebug("unexpected imageRailItem: \(imageRailItem)")
return
}
guard let currentItem: SignalAttachmentItem = currentItem, let currentIndex = attachmentItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return
}
guard let targetIndex = attachmentItems.firstIndex(of: targetItem) else {
owsFailDebug("targetIndex was unexpectedly nil")
return
}
let direction: UIPageViewController.NavigationDirection = (currentIndex < targetIndex ? .forward : .reverse)
self.setCurrentItem(targetItem, direction: direction, animated: true)
}
}
// MARK: -
extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate {
func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) {
remove(attachmentItem: attachmentItem)
}
func canRemoveApprovalRailCellView(_ approvalRailCellView: ApprovalRailCellView) -> Bool {
return self.attachmentItems.count > 1
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentApprovalInputAccessoryViewDelegate {
public func attachmentApprovalInputUpdateMediaRail() {
updateMediaRail()
}
public func attachmentApprovalInputStartEditingCaptions() {
isEditingCaptions = true
}
public func attachmentApprovalInputStopEditingCaptions() {
isEditingCaptions = false
}
}