// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation @objc(OWSMediaView) public class MediaView: UIView { private enum MediaError { case missing case invalid case failed } // MARK: - private let mediaCache: NSCache @objc public let attachment: TSAttachment private let isOutgoing: Bool private let maxMessageWidth: CGFloat private var loadBlock: (() -> Void)? private var unloadBlock: (() -> Void)? // MARK: - LoadState // The loadState property allows us to: // // * Make sure we only have one load attempt // enqueued at a time for a given piece of media. // * We never retry media that can't be loaded. // * We skip media loads which are no longer // necessary by the time they reach the front // of the queue. enum LoadState { case unloaded case loading case loaded case failed } // Thread-safe access to load state. // // We use a "box" class so that we can capture a reference // to this box (rather than self) and a) safely access // if off the main thread b) not prevent deallocation of // self. private class ThreadSafeLoadState { private var value: LoadState required init(_ value: LoadState) { self.value = value } func get() -> LoadState { objc_sync_enter(self) let valueCopy = value objc_sync_exit(self) return valueCopy } func set(_ newValue: LoadState) { objc_sync_enter(self) value = newValue objc_sync_exit(self) } } private let threadSafeLoadState = ThreadSafeLoadState(.unloaded) // Convenience accessors. private var loadState: LoadState { get { return threadSafeLoadState.get() } set { threadSafeLoadState.set(newValue) } } // MARK: - Initializers @objc public required init(mediaCache: NSCache, attachment: TSAttachment, isOutgoing: Bool, maxMessageWidth: CGFloat) { self.mediaCache = mediaCache self.attachment = attachment self.isOutgoing = isOutgoing self.maxMessageWidth = maxMessageWidth super.init(frame: .zero) backgroundColor = Colors.unimportant clipsToBounds = true createContents() } @available(*, unavailable, message: "use other init() instead.") required public init?(coder aDecoder: NSCoder) { notImplemented() } deinit { AssertIsOnMainThread() loadState = .unloaded } // MARK: - private func createContents() { AssertIsOnMainThread() guard let attachmentStream = attachment as? TSAttachmentStream else { addDownloadProgressIfNecessary() return } guard !isFailedDownload else { configure(forError: .failed) return } if attachmentStream.isAnimated { configureForAnimatedImage(attachmentStream: attachmentStream) } else if attachmentStream.isImage { configureForStillImage(attachmentStream: attachmentStream) } else if attachmentStream.isVideo { configureForVideo(attachmentStream: attachmentStream) } else { owsFailDebug("Attachment has unexpected type.") configure(forError: .invalid) } } private func addDownloadProgressIfNecessary() { guard !isFailedDownload else { configure(forError: .failed) return } guard let attachmentPointer = attachment as? TSAttachmentPointer else { owsFailDebug("Attachment has unexpected type.") configure(forError: .invalid) return } guard attachmentPointer.pointerType == .incoming else { // TODO: Show "restoring" indicator and possibly progress. configure(forError: .missing) return } backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05) let loader = MediaLoaderView() addSubview(loader) loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) } private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { guard isOutgoing else { return false } guard let attachmentStream = attachment as? TSAttachmentStream else { return false } guard !attachmentStream.isUploaded else { return false } let loader = MediaLoaderView() addSubview(loader) loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) return true } private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) { guard let cacheKey = attachmentStream.uniqueId else { owsFailDebug("Attachment stream missing unique ID.") return } 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 = .scaleAspectFill // Use trilinear filters for better scaling quality at // some performance cost. animatedImageView.layer.minificationFilter = .trilinear animatedImageView.layer.magnificationFilter = .trilinear animatedImageView.backgroundColor = Colors.unimportant addSubview(animatedImageView) animatedImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(animatedImageView) loadBlock = { [weak self] in AssertIsOnMainThread() guard let strongSelf = self else { return } if animatedImageView.image != nil { owsFailDebug("Unexpectedly already loaded.") return } strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in guard attachmentStream.isValidImage else { Logger.warn("Ignoring invalid attachment.") return nil } guard let filePath = attachmentStream.originalFilePath else { owsFailDebug("Attachment stream missing original file path.") return nil } let animatedImage = YYImage(contentsOfFile: filePath) return animatedImage }, applyMediaBlock: { (media) in AssertIsOnMainThread() guard let image = media as? YYImage else { owsFailDebug("Media has unexpected type: \(type(of: media))") return } animatedImageView.image = image }, cacheKey: cacheKey) } unloadBlock = { AssertIsOnMainThread() animatedImageView.image = nil } } private func configureForStillImage(attachmentStream: TSAttachmentStream) { guard let cacheKey = attachmentStream.uniqueId else { owsFailDebug("Attachment stream missing unique ID.") return } let stillImageView = UIImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. stillImageView.contentMode = .scaleAspectFill // Use trilinear filters for better scaling quality at // some performance cost. stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear stillImageView.backgroundColor = Colors.unimportant addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(stillImageView) loadBlock = { [weak self] in AssertIsOnMainThread() if stillImageView.image != nil { owsFailDebug("Unexpectedly already loaded.") return } self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in guard attachmentStream.isValidImage else { Logger.warn("Ignoring invalid attachment.") return nil } return attachmentStream.thumbnailImageLarge(success: { (image) in AssertIsOnMainThread() stillImageView.image = image }, failure: { Logger.error("Could not load thumbnail") }) }, applyMediaBlock: { (media) in AssertIsOnMainThread() guard let image = media as? UIImage else { owsFailDebug("Media has unexpected type: \(type(of: media))") return } stillImageView.image = image }, cacheKey: cacheKey) } unloadBlock = { AssertIsOnMainThread() stillImageView.image = nil } } private func configureForVideo(attachmentStream: TSAttachmentStream) { guard let cacheKey = attachmentStream.uniqueId else { owsFailDebug("Attachment stream missing unique ID.") return } let stillImageView = UIImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. stillImageView.contentMode = .scaleAspectFill // Use trilinear filters for better scaling quality at // some performance cost. stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear stillImageView.backgroundColor = Colors.unimportant addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() if !addUploadProgressIfNecessary(stillImageView) { let videoPlayIcon = UIImage(named: "CirclePlay") let videoPlayButton = UIImageView(image: videoPlayIcon) videoPlayButton.set(.width, to: 72) videoPlayButton.set(.height, to: 72) stillImageView.addSubview(videoPlayButton) videoPlayButton.autoCenterInSuperview() } loadBlock = { [weak self] in AssertIsOnMainThread() if stillImageView.image != nil { owsFailDebug("Unexpectedly already loaded.") return } self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in guard attachmentStream.isValidVideo else { Logger.warn("Ignoring invalid attachment.") return nil } return attachmentStream.thumbnailImageMedium(success: { (image) in AssertIsOnMainThread() stillImageView.image = image }, failure: { Logger.error("Could not load thumbnail") }) }, applyMediaBlock: { (media) in AssertIsOnMainThread() guard let image = media as? UIImage else { owsFailDebug("Media has unexpected type: \(type(of: media))") return } stillImageView.image = image }, cacheKey: cacheKey) } unloadBlock = { AssertIsOnMainThread() stillImageView.image = nil } } private var isFailedDownload: Bool { guard let attachmentPointer = attachment as? TSAttachmentPointer else { return false } return attachmentPointer.state == .failed } private func configure(forError error: MediaError) { backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05) let icon: UIImage switch error { case .failed: guard let asset = UIImage(named: "media_retry") else { owsFailDebug("Missing image") return } icon = asset case .invalid: guard let asset = UIImage(named: "media_invalid") else { owsFailDebug("Missing image") return } icon = asset case .missing: return } let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addSubview(iconView) iconView.autoCenterInSuperview() } private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?, applyMediaBlock: @escaping (AnyObject) -> Void, cacheKey: String) { AssertIsOnMainThread() // It's critical that we update loadState once // our load attempt is complete. let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in AssertIsOnMainThread() guard let strongSelf = self else { return } guard strongSelf.loadState == .loading else { Logger.verbose("Skipping obsolete load.") return } guard let media = possibleMedia else { strongSelf.loadState = .failed // TODO: // [self showAttachmentErrorViewWithMediaView:mediaView]; return } applyMediaBlock(media) strongSelf.loadState = .loaded } guard loadState == .loading else { owsFailDebug("Unexpected load state: \(loadState)") return } let mediaCache = self.mediaCache if let media = mediaCache.object(forKey: cacheKey as NSString) { Logger.verbose("media cache hit") loadCompletion(media) return } Logger.verbose("media cache miss") let threadSafeLoadState = self.threadSafeLoadState MediaView.loadQueue.async { guard threadSafeLoadState.get() == .loading else { Logger.verbose("Skipping obsolete load.") return } guard let media = loadMediaBlock() else { Logger.error("Failed to load media.") DispatchQueue.main.async { loadCompletion(nil) } return } DispatchQueue.main.async { mediaCache.setObject(media, forKey: cacheKey as NSString) loadCompletion(media) } } } // We use this queue to perform the media loads. // These loads are expensive, so we want to: // // * Do them off the main thread. // * Only do one at a time. // * Avoid this work if possible (obsolete loads for // views that are no longer visible, redundant loads // of media already being loaded, don't retry media // that can't be loaded, etc.). // * Do them in _reverse_ order. More recently enqueued // loads more closely reflect the current view state. // By processing in reverse order, we improve our // "skip rate" of obsolete loads. private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue") @objc public func loadMedia() { AssertIsOnMainThread() switch loadState { case .unloaded: loadState = .loading guard let loadBlock = loadBlock else { return } loadBlock() case .loading, .loaded, .failed: break } } @objc public func unloadMedia() { AssertIsOnMainThread() loadState = .unloaded guard let unloadBlock = unloadBlock else { return } unloadBlock() } }