session-ios/Session/Conversations/Message Cells/Content Views/MediaView.swift
Morgan Pretty 4d5ded7557 Fixed a few bugs with media attachment handling, added webp support
Updated the OpenGroupManager to create a BlindedIdLookup for messages within the `inbox` (validating that the sessionId does actually match the blindedId)
Added support for static and animated WebP images
Added basic support for HEIC and HEIF images
Fixed an issue where the file size limit was set to 10,000,000 bytes instead of 10,485,760 bytes (which is actually 10Mb)
Fixed an issue where attachments uploaded by the current user on other devices would always show a loading indicator
Fixed an issue where media attachments that don't contain width/height information in their protos weren't updating the values once the download was completed
Fixed an issue where the media view could download an invalid file and endlessly appear to be downloading
2022-07-29 15:26:24 +10:00

464 lines
16 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import SessionUIKit
import SessionMessagingKit
public class MediaView: UIView {
static let contentMode: UIView.ContentMode = .scaleAspectFill
private enum MediaError {
case missing
case invalid
case failed
}
// MARK: -
private let mediaCache: NSCache<NSString, AnyObject>
public let attachment: Attachment
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
}
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
// MARK: - Initializers
public required init(
mediaCache: NSCache<NSString, AnyObject>,
attachment: Attachment,
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 {
loadState.mutate { $0 = .unloaded }
}
// MARK: -
private func createContents() {
AssertIsOnMainThread()
guard attachment.state != .pendingDownload && attachment.state != .downloading else {
addDownloadProgressIfNecessary()
return
}
guard attachment.state != .failedDownload else {
configure(forError: .failed)
return
}
guard attachment.isValid else {
configure(forError: .invalid)
return
}
if attachment.isAnimated {
configureForAnimatedImage(attachment: attachment)
}
else if attachment.isImage {
configureForStillImage(attachment: attachment)
}
else if attachment.isVideo {
configureForVideo(attachment: attachment)
}
else {
owsFailDebug("Attachment has unexpected type.")
configure(forError: .invalid)
}
}
private func addDownloadProgressIfNecessary() {
guard attachment.state != .failedDownload else {
configure(forError: .failed)
return
}
guard attachment.state != .uploading && attachment.state != .uploaded else {
// TODO: Show "restoring" indicator and possibly progress.
configure(forError: .missing)
return
}
backgroundColor = (isDarkMode ? .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 attachment.state != .failedUpload else {
configure(forError: .failed)
return false
}
// If this message was uploaded on a different device it'll now be seen as 'downloaded' (but
// will still be outgoing - we don't want to show a loading indicator in this case)
guard attachment.state != .uploaded && attachment.state != .downloaded 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(attachment: Attachment) {
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
animatedImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.backgroundColor = Colors.unimportant
animatedImageView.isHidden = !attachment.isValid
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: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
guard let filePath: String = attachment.originalFilePath else {
owsFailDebug("Attachment stream missing original file path.")
self?.configure(forError: .invalid)
return
}
applyMediaBlock(YYImage(contentsOfFile: filePath))
},
applyMediaBlock: { media in
AssertIsOnMainThread()
guard let image: YYImage = media as? YYImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
animatedImageView.image = image
},
cacheKey: attachment.id
)
}
unloadBlock = {
AssertIsOnMainThread()
animatedImageView.image = nil
}
}
private func configureForStillImage(attachment: Attachment) {
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 = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Colors.unimportant
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(stillImageView)
loadBlock = { [weak self] in
AssertIsOnMainThread()
if stillImageView.image != nil {
owsFailDebug("Unexpectedly already loaded.")
return
}
self?.tryToLoadMedia(
loadMediaBlock: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
attachment.thumbnail(
size: .large,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image
},
cacheKey: attachment.id
)
}
unloadBlock = {
AssertIsOnMainThread()
stillImageView.image = nil
}
}
private func configureForVideo(attachment: Attachment) {
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 = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Colors.unimportant
stillImageView.isHidden = !attachment.isValid
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: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
attachment.thumbnail(
size: .medium,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image
},
cacheKey: attachment.id
)
}
unloadBlock = {
AssertIsOnMainThread()
stillImageView.image = nil
}
}
private func configure(forError error: MediaError) {
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
}
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
// For failed ougoing messages add an overlay to make the icon more visible
if isOutgoing {
let attachmentOverlayView: UIView = UIView()
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
.withAlphaComponent(Values.lowOpacity)
addSubview(attachmentOverlayView)
attachmentOverlayView.pin(to: self)
}
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
iconView.tintColor = Colors.text
.withAlphaComponent(Values.mediumOpacity)
addSubview(iconView)
iconView.autoCenterInSuperview()
}
private func tryToLoadMedia(
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
applyMediaBlock: @escaping (AnyObject) -> Void,
cacheKey: String
) {
// It's critical that we update loadState once
// our load attempt is complete.
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
guard self?.loadState.wrappedValue == .loading else {
Logger.verbose("Skipping obsolete load.")
return
}
guard let media: AnyObject = possibleMedia else {
self?.loadState.mutate { $0 = .failed }
// TODO:
// [self showAttachmentErrorViewWithMediaView:mediaView];
return
}
applyMediaBlock(media)
self?.mediaCache.setObject(media, forKey: cacheKey as NSString)
self?.loadState.mutate { $0 = .loaded }
}
guard loadState.wrappedValue == .loading else {
owsFailDebug("Unexpected load state: \(loadState)")
return
}
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
Logger.verbose("media cache hit")
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
loadCompletion(media)
return
}
Logger.verbose("media cache miss")
MediaView.loadQueue.async { [weak self] in
guard self?.loadState.wrappedValue == .loading else {
Logger.verbose("Skipping obsolete load.")
return
}
loadMediaBlock { media in
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
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")
public func loadMedia() {
switch loadState.wrappedValue {
case .unloaded:
loadState.mutate { $0 = .loading }
loadBlock?()
case .loading, .loaded, .failed: break
}
}
public func unloadMedia() {
loadState.mutate { $0 = .unloaded }
unloadBlock?()
}
}