Signal-iOS/Signal/src/ViewControllers/ViewOnceMessageViewController.swift
2019-08-01 16:22:53 -03:00

447 lines
17 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
@objc
class ViewOnceMessageViewController: OWSViewController {
class Content {
let messageId: String
let filePath: String
// The content is an animated image (GIF or WEBP)
// if true, a still image otherwise.
// TODO: We could eventually support video.
let isAnimated: Bool
init(messageId: String,
filePath: String,
isAnimated: Bool) {
self.messageId = messageId
self.filePath = filePath
self.isAnimated = isAnimated
}
deinit {
Logger.verbose("Cleaning up temp file")
let filePath = self.filePath
DispatchQueue.global().async {
OWSFileSystem.deleteFile(filePath)
}
}
}
// MARK: - Dependencies
static var databaseStorage: SDSDatabaseStorage {
return SDSDatabaseStorage.shared
}
var databaseStorage: SDSDatabaseStorage {
return SDSDatabaseStorage.shared
}
// MARK: - Properties
private let content: Content
// MARK: - Initializers
required init(content: Content) {
self.content = content
super.init(nibName: nil, bundle: nil)
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: -
@objc
public class func tryToPresent(interaction: TSInteraction,
from fromViewController: UIViewController) {
AssertIsOnMainThread()
ModalActivityIndicatorViewController.present(fromViewController: fromViewController,
canCancel: false) { (modal) in
DispatchQueue.main.async {
let content: Content? = loadContentForPresentation(interaction: interaction)
modal.dismiss(completion: {
guard let content = content else {
owsFailDebug("Could not present interaction")
// TODO: Show an alert.
return
}
let view = ViewOnceMessageViewController(content: content)
fromViewController.present(view, animated: true)
})
}
}
}
private class func loadContentForPresentation(interaction: TSInteraction) -> Content? {
var content: Content?
// The only way to ensure that the content is never presented
// more than once is to do a bunch of work (include file system
// activity) inside a write transaction, which normally
// wouldn't be desirable.
databaseStorage.write { transaction in
guard let interactionId = interaction.uniqueId else {
return
}
guard let message = TSInteraction.anyFetch(uniqueId: interactionId, transaction: transaction) as? TSMessage else {
return
}
guard message.isViewOnceMessage else {
owsFailDebug("Unexpected message.")
return
}
guard let messageId = message.uniqueId else {
owsFailDebug("Missing messageid.")
return
}
// Auto-complete the message before going any further.
ViewOnceMessages.completeIfNecessary(message: message, transaction: transaction)
guard !message.isViewOnceComplete else {
return
}
// We should _always_ mark the message as complete,
// even if the message is malformed, or if we fail
// to do the "file system dance" below, etc.
// and we fail to present the message content.
defer {
// This will eliminate the renderable content of the message.
ViewOnceMessages.markAsComplete(message: message, sendSyncMessages: true, transaction: transaction)
}
guard let attachmentId = message.attachmentIds.firstObject as? String else {
return
}
guard let attachmentStream = TSAttachment.anyFetch(uniqueId: attachmentId, transaction: transaction) as? TSAttachmentStream else {
return
}
guard attachmentStream.isValidVisualMedia else {
return
}
let contentType = attachmentStream.contentType
guard contentType.count > 0 else {
owsFailDebug("Missing content type.")
return
}
let isAnimated: Bool
if attachmentStream.isAnimated || contentType == OWSMimeTypeImageWebp {
isAnimated = true
} else if attachmentStream.isImage {
isAnimated = false
} else {
owsFailDebug("Unexpected content type.")
return
}
// To ensure that we never show the content more than once,
// we mark the "view-once message" as complete _before_
// presenting its contents. A side effect of this is that
// its renderable content is deleted. We need the renderable
// content to present it. Therefore, we do a little dance:
//
// * Move the attachment file to a temporary file.
// * Create an empty placeholder file in the old attachment
// file's location so that TSAttachmentStream's invariant
// of always corresponding to an underlying file on disk
// remains true.
// * Delete the temporary file when this view is dismissed.
// * If the app terminates at any step during this process,
// either: a) the file wasn't moved, the message wasn't
// marked as complete and the content wasn't displayed
// so the user can try again after relaunch.
// b) the file was moved and will be cleaned up on next
// launch like any other temp file if it hasn't been
// deleted already.
guard let originalFilePath = attachmentStream.originalFilePath else {
owsFailDebug("Attachment missing file path.")
return
}
guard OWSFileSystem.fileOrFolderExists(atPath: originalFilePath) else {
owsFailDebug("Missing temp file.")
return
}
guard let fileExtension = MIMETypeUtil.fileExtension(forMIMEType: contentType) else {
owsFailDebug("Couldn't determine file extension.")
return
}
let tempFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension)
guard OWSFileSystem.fileOrFolderExists(atPath: originalFilePath) else {
owsFailDebug("Missing attachment file.")
return
}
guard !OWSFileSystem.fileOrFolderExists(atPath: tempFilePath) else {
owsFailDebug("Temp file unexpectedly already exists.")
return
}
// Move the attachment to the temp file.
// A copy would be much more expensive.
guard OWSFileSystem.moveFilePath(originalFilePath, toFilePath: tempFilePath) else {
owsFailDebug("Couldn't move file.")
return
}
guard OWSFileSystem.fileOrFolderExists(atPath: tempFilePath) else {
owsFailDebug("Missing placeholder file.")
return
}
// This should be redundant since temp files are
// created inside the per-launch temp folder
// and should inherit protection from it.
guard OWSFileSystem.protectFileOrFolder(atPath: tempFilePath) else {
owsFailDebug("Couldn't protect temp file.")
OWSFileSystem.deleteFile(tempFilePath)
return
}
// Create new empty "placeholder file at the attachment's old
// location, since the attachment model should always correspond
// to an underlying file on disk.
guard OWSFileSystem.ensureFileExists(originalFilePath) else {
owsFailDebug("Couldn't create placeholder file.")
return
}
guard OWSFileSystem.fileOrFolderExists(atPath: originalFilePath) else {
owsFailDebug("Missing placeholder file.")
return
}
content = Content(messageId: messageId,
filePath: tempFilePath,
isAnimated: isAnimated)
}
return content
}
// MARK: - View Lifecycle
override public func loadView() {
self.view = UIView()
view.backgroundColor = UIColor.ows_black
self.modalPresentationStyle = .overFullScreen
let contentView = UIView()
view.addSubview(contentView)
contentView.autoPinWidthToSuperview()
contentView.autoPin(toTopLayoutGuideOf: self, withInset: 0)
contentView.autoPin(toBottomLayoutGuideOf: self, withInset: 0)
let defaultMediaView = UIView()
defaultMediaView.backgroundColor = Theme.darkThemeOffBackgroundColor
let mediaView = buildMediaView() ?? defaultMediaView
contentView.addSubview(mediaView)
mediaView.autoPinEdgesToSuperviewEdges()
let hMargin: CGFloat = 16
let vMargin: CGFloat = 20
let controlSize: CGFloat = 24
let controlSpacing: CGFloat = 20
let controlsWidth = controlSize * 2 + controlSpacing + hMargin * 2
let controlsHeight = controlSize + vMargin * 2
mediaView.autoSetDimension(.width, toSize: controlsWidth, relation: .greaterThanOrEqual)
mediaView.autoSetDimension(.height, toSize: controlsHeight, relation: .greaterThanOrEqual)
let dismissButton = OWSButton(imageName: "x-24", tintColor: Theme.darkThemePrimaryColor) { [weak self] in
self?.dismissButtonPressed()
}
dismissButton.contentEdgeInsets = UIEdgeInsets(top: vMargin, leading: hMargin, bottom: vMargin, trailing: hMargin)
view.addSubview(dismissButton)
dismissButton.autoPinEdge(.leading, to: .leading, of: mediaView)
dismissButton.autoPinEdge(.top, to: .top, of: mediaView)
dismissButton.setShadow(opacity: 0.66)
view.addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(rootViewWasTapped)))
let verticalSwipe = UISwipeGestureRecognizer(target: self, action: #selector(rootViewWasSwiped))
verticalSwipe.direction = [.up, .down]
view.addGestureRecognizer(verticalSwipe)
setupDatabaseObservation()
}
private func buildMediaView() -> UIView? {
let filePath = content.filePath
if content.isAnimated {
guard let image = YYImage(contentsOfFile: filePath) else {
owsFailDebug("Could not load attachment.")
return nil
}
guard image.size.width > 0,
image.size.height > 0 else {
owsFailDebug("Attachment has invalid size.")
return nil
}
let animatedImageView = YYAnimatedImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
animatedImageView.contentMode = .scaleAspectFit
// Use trilinear filters for better scaling quality at
// some performance cost.
animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.layer.allowsEdgeAntialiasing = true
animatedImageView.image = image
return animatedImageView
} else {
guard let image = UIImage(contentsOfFile: filePath) else {
owsFailDebug("Could not load attachment.")
return nil
}
guard image.size.width > 0,
image.size.height > 0 else {
owsFailDebug("Attachment has invalid size.")
return nil
}
let imageView = UIImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
imageView.contentMode = .scaleAspectFit
// Use trilinear filters for better scaling quality at
// some performance cost.
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
imageView.layer.allowsEdgeAntialiasing = true
imageView.image = image
return imageView
}
}
func setupDatabaseObservation() {
if FeatureFlags.useGRDB {
guard let observer = databaseStorage.grdbStorage.conversationViewDatabaseObserver else {
owsFailDebug("observer was unexpectedly nil")
return
}
observer.appendSnapshotDelegate(self)
} else {
NotificationCenter.default.addObserver(self,
selector: #selector(uiDatabaseDidUpdate),
name: .OWSUIDatabaseConnectionDidUpdateExternally,
object: OWSPrimaryStorage.shared().dbNotificationObject)
NotificationCenter.default.addObserver(self,
selector: #selector(uiDatabaseDidUpdate),
name: .OWSUIDatabaseConnectionDidUpdate,
object: OWSPrimaryStorage.shared().dbNotificationObject)
}
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillEnterForeground),
name: .OWSApplicationWillEnterForeground,
object: nil)
}
public override var canBecomeFirstResponder: Bool {
return true
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.becomeFirstResponder()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.becomeFirstResponder()
}
public override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
// MARK: - Video
// Once open, this view only dismisses if the message is deleted
// (e.g. by per-conversation expiration).
private func dismissIfRemoved() {
AssertIsOnMainThread()
let shouldDismiss: Bool = databaseStorage.uiReadReturningResult { transaction in
let uniqueId = self.content.messageId
guard TSInteraction.anyFetch(uniqueId: uniqueId, transaction: transaction) != nil else {
return true
}
return false
}
if shouldDismiss {
self.dismiss(animated: true)
}
}
// MARK: - Events
@objc internal func uiDatabaseDidUpdate(notification: NSNotification) {
AssertIsOnMainThread()
Logger.debug("")
dismissIfRemoved()
}
@objc
func applicationWillEnterForeground() throws {
AssertIsOnMainThread()
Logger.debug("")
dismissIfRemoved()
}
@objc
private func dismissButtonPressed() {
AssertIsOnMainThread()
dismiss(animated: true)
}
@objc
func rootViewWasTapped(sender: UIGestureRecognizer) {
AssertIsOnMainThread()
dismiss(animated: true)
}
@objc
func rootViewWasSwiped(sender: UIGestureRecognizer) {
AssertIsOnMainThread()
dismiss(animated: true)
}
}
// MARK: -
extension ViewOnceMessageViewController: ConversationViewDatabaseSnapshotDelegate {
public func conversationViewDatabaseSnapshotWillUpdate() {
dismissIfRemoved()
}
public func conversationViewDatabaseSnapshotDidUpdate(transactionChanges: ConversationViewDatabaseTransactionChanges) {
dismissIfRemoved()
}
public func conversationViewDatabaseSnapshotDidUpdateExternally() {
dismissIfRemoved()
}
public func conversationViewDatabaseSnapshotDidReset() {
dismissIfRemoved()
}
}