// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Lottie import SignalServiceKit import SignalUI // A view for presenting attachment upload/download/failure/pending state. public class CVAttachmentProgressView: ManualLayoutView { public enum Direction { case upload(attachmentStream: TSResourceStream) case download(attachmentPointer: TSResourcePointer, transitTierDownloadState: AttachmentDownloadState) var attachmentId: TSResourceId { switch self { case .upload(let attachmentStream): return attachmentStream.resourceId case .download(let attachmentPointer, _): return attachmentPointer.resourceId } } } private let direction: Direction private let diameter: CGFloat private let isDarkThemeEnabled: Bool private let stateView: StateView private var attachmentId: TSResourceId { direction.attachmentId } public init( direction: Direction, diameter: CGFloat = 44, isDarkThemeEnabled: Bool, mediaCache: CVMediaCache ) { self.direction = direction self.diameter = diameter self.isDarkThemeEnabled = isDarkThemeEnabled self.stateView = StateView(diameter: diameter, direction: direction, isDarkThemeEnabled: isDarkThemeEnabled, mediaCache: mediaCache) super.init(name: "CVAttachmentProgressView") createViews() configureState() } private enum State: Equatable { case none case tapToDownload case downloadFailed case downloadUnknownProgress case uploadUnknownProgress case downloadProgress(progress: CGFloat) case uploadProgress(progress: CGFloat) var debugDescription: String { switch self { case .none: return "none" case .tapToDownload: return "tapToDownload" case .downloadFailed: return "downloadFailed" case .downloadUnknownProgress: return "downloadUnknownProgress" case .uploadUnknownProgress: return "uploadUnknownProgress" case .downloadProgress(let progress): return "downloadProgress: \(progress)" case .uploadProgress(let progress): return "uploadProgress: \(progress)" } } } private class StateView: ManualLayoutView { private let diameter: CGFloat private let direction: Direction private let isDarkThemeEnabled: Bool private lazy var imageView = CVImageView() private var unknownProgressView: LottieAnimationView? private var progressView: LottieAnimationView? private let mediaCache: CVMediaCache var state: State = .none { didSet { if oldValue != state { applyState(oldState: oldValue, newState: state) } } } private var isIncoming: Bool { switch direction { case .upload: return false case .download: return true } } init(diameter: CGFloat, direction: Direction, isDarkThemeEnabled: Bool, mediaCache: CVMediaCache) { self.diameter = diameter self.direction = direction self.isDarkThemeEnabled = isDarkThemeEnabled self.mediaCache = mediaCache super.init(name: "CVAttachmentProgressView.StateView") applyState(oldState: .none, newState: .none) } private func applyState(oldState: State, newState: State) { switch newState { case .none: reset() case .tapToDownload: if oldState != newState { presentIcon(templateName: Theme.iconName(.arrowDown), isInsideProgress: false) } case .downloadFailed: if oldState != newState { presentIcon(templateName: Theme.iconName(.refresh), isInsideProgress: false) } case .downloadProgress(let progress): switch oldState { case .downloadProgress: updateProgress(progress: progress) default: presentProgress(progress: progress) presentIcon(templateName: Theme.iconName(.buttonX), isInsideProgress: true) } case .uploadProgress(let progress): switch oldState { case .uploadProgress: updateProgress(progress: progress) default: presentProgress(progress: progress) } case .downloadUnknownProgress: presentUnknownProgress() presentIcon(templateName: Theme.iconName(.buttonX), isInsideProgress: true) case .uploadUnknownProgress: presentUnknownProgress() } } private func presentIcon(templateName: String, isInsideProgress: Bool) { if !isInsideProgress { reset() } imageView.setTemplateImageName(templateName, tintColor: .ows_white) addSubviewToCenterOnSuperview(imageView, size: .square(floor(0.44 * diameter))) } private func presentProgress(progress: CGFloat) { reset() let animationName: String if diameter <= 44 { animationName = "determinate_spinner_44" } else { animationName = "determinate_spinner_56" } let animationView = ensureAnimationView(progressView, animationName: animationName) owsAssertDebug(animationView.animation != nil) progressView = animationView animationView.backgroundBehavior = .pause animationView.loopMode = .playOnce animationView.contentMode = .scaleAspectFit // We DO NOT play this animation; we "scrub" it to reflect // attachment upload/download progress. updateProgress(progress: progress) addSubviewToFillSuperviewEdges(animationView) } private func presentUnknownProgress() { reset() let animationName: String if diameter <= 44 { animationName = "indeterminate_spinner_44" } else { animationName = "indeterminate_spinner_56" } let animationView = ensureAnimationView(unknownProgressView, animationName: animationName) owsAssertDebug(animationView.animation != nil) unknownProgressView = animationView animationView.backgroundBehavior = .pauseAndRestore animationView.loopMode = .loop animationView.contentMode = .scaleAspectFit animationView.play() addSubviewToFillSuperviewEdges(animationView) } private func ensureAnimationView(_ animationView: LottieAnimationView?, animationName: String) -> LottieAnimationView { if let animationView = animationView { return animationView } else { return mediaCache.buildLottieAnimationView(name: animationName) } } private func updateProgress(progress: CGFloat) { guard let progressView = progressView else { owsFailDebug("Missing progressView.") return } guard let animation = progressView.animation else { owsFailDebug("Missing animation.") return } // We DO NOT play this animation; we "scrub" it to reflect // attachment upload/download progress. progressView.currentFrame = progress.lerp(animation.startFrame, animation.endFrame) } public override func reset() { super.reset() progressView?.stop() unknownProgressView?.stop() imageView.image = nil } } private func createViews() { let innerContentView = self.stateView let circleView = ManualLayoutView.circleView(name: "circleView") circleView.backgroundColor = .ows_blackAlpha50 circleView.addSubviewToCenterOnSuperview(innerContentView, size: .square(diameter)) addSubviewToFillSuperviewEdges(circleView) } public var layoutSize: CGSize { .square(diameter) } private func configureState() { switch direction { case .upload(let attachmentStream): stateView.state = .uploadUnknownProgress updateUploadProgress(attachmentStream: attachmentStream) NotificationCenter.default.addObserver( self, selector: #selector(processUploadNotification(notification:)), name: Upload.Constants.resourceUploadProgressNotification, object: nil ) case .download(_, let transitTierDownloadState): switch transitTierDownloadState { case .failed: stateView.state = .downloadFailed case .none: stateView.state = .tapToDownload case .enqueued, .downloading: updateDownloadProgress(nil) NotificationCenter.default.addObserver( self, selector: #selector(processDownloadNotification(notification:)), name: TSResourceDownloads.attachmentDownloadProgressNotification, object: nil ) } } } @objc private func processDownloadNotification(notification: Notification) { guard let attachmentId = notification.userInfo?[TSResourceDownloads.attachmentDownloadAttachmentIDKey] as? TSResourceId else { owsFailDebug("Missing notificationAttachmentId.") return } guard attachmentId == self.attachmentId else { return } let progress = notification.userInfo?[TSResourceDownloads.attachmentDownloadProgressKey] as? CGFloat updateDownloadProgress(progress) } private func updateDownloadProgress(_ progress: CGFloat?) { AssertIsOnMainThread() guard let progress else { Logger.warn("No progress for attachment.") stateView.state = .downloadUnknownProgress return } updateState(downloadProgress: progress) } private func updateState(downloadProgress progress: CGFloat?) { guard let progress = progress else { stateView.state = .downloadUnknownProgress return } if progress.isNaN { owsFailDebug("Progress is nan.") stateView.state = .downloadUnknownProgress } else if progress > 0 { stateView.state = .downloadProgress(progress: CGFloat(progress)) } else { stateView.state = .downloadUnknownProgress } } @objc private func processUploadNotification(notification: Notification) { guard let notificationAttachmentId = notification.userInfo?[Upload.Constants.uploadResourceIDKey] as? TSResourceId else { owsFailDebug("Missing notificationAttachmentId.") return } guard notificationAttachmentId == attachmentId else { return } guard let progress = notification.userInfo?[Upload.Constants.uploadProgressKey] as? NSNumber else { owsFailDebug("Missing progress.") stateView.state = .uploadUnknownProgress return } switch direction { case .upload(let attachmentStream): guard !attachmentStream.isUploadedToTransitTier else { stateView.state = .uploadProgress(progress: 1) return } case .download: owsFailDebug("Invalid attachment.") stateView.state = .uploadUnknownProgress return } updateState(uploadProgress: progress) } private func updateState(uploadProgress progress: NSNumber?) { guard let progress = progress?.floatValue else { stateView.state = .uploadUnknownProgress return } if progress.isNaN { owsFailDebug("Progress is nan.") stateView.state = .uploadUnknownProgress } else if progress > 0 { stateView.state = .uploadProgress(progress: CGFloat(progress)) } else { stateView.state = .uploadUnknownProgress } } private func updateUploadProgress(attachmentStream: TSResourceStream) { AssertIsOnMainThread() if attachmentStream.isUploadedToTransitTier { stateView.state = .uploadProgress(progress: 1) } else { stateView.state = .uploadUnknownProgress } } public enum ProgressType { case none case uploading(attachmentStream: TSResourceStream) case pendingDownload(attachmentPointer: TSResourcePointer) case downloading(attachmentPointer: TSResourcePointer, transitTierDownloadState: AttachmentDownloadState) case unknown } public static func progressType( forAttachment attachment: CVAttachment, interaction: TSInteraction ) -> ProgressType { switch attachment { case .stream(let attachmentStream): if let outgoingMessage = interaction as? TSOutgoingMessage { let hasSendFailed = outgoingMessage.messageState == .failed let wasNotCreatedLocally = outgoingMessage.wasNotCreatedLocally guard !attachmentStream.attachmentStream.isUploadedToTransitTier, !wasNotCreatedLocally, !hasSendFailed else { return .none } return .uploading(attachmentStream: attachmentStream.attachmentStream) } else if interaction is TSIncomingMessage { return .none } else { owsFailDebug("Unexpected interaction: \(type(of: interaction))") return .unknown } case .pointer(let attachmentPointer, let transitTierDownloadState): switch transitTierDownloadState { case .none: return .pendingDownload(attachmentPointer: attachmentPointer.attachmentPointer) case .failed, .enqueued, .downloading: return .downloading( attachmentPointer: attachmentPointer.attachmentPointer, transitTierDownloadState: transitTierDownloadState ) } } } }