447 lines
17 KiB
Swift
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()
|
|
}
|
|
}
|