TM-SGNL-iOS/Signal/ConversationView/CellViews/CVAttachmentProgressView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

439 lines
15 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
public import SignalServiceKit
public import SignalUI
// A view for presenting attachment upload/download/failure/pending state.
public class CVAttachmentProgressView: ManualLayoutView {
public enum Direction {
case upload(attachmentStream: AttachmentStream)
case download(attachmentPointer: AttachmentTransitPointer, transitTierDownloadState: AttachmentDownloadState)
var attachmentId: Attachment.IDType {
switch self {
case .upload(let attachmentStream):
return attachmentStream.id
case .download(let attachmentPointer, _):
return attachmentPointer.id
}
}
}
private let direction: Direction
private let diameter: CGFloat
private let isDarkThemeEnabled: Bool
private let stateView: StateView
private var attachmentId: Attachment.IDType { 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.attachmentUploadProgressNotification,
object: nil
)
case .download(_, let transitTierDownloadState):
switch transitTierDownloadState {
case .failed:
stateView.state = .downloadFailed
case .none:
stateView.state = .tapToDownload
case .enqueuedOrDownloading:
updateDownloadProgress(nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadProgressNotification,
object: nil
)
}
}
}
@objc
private func processDownloadNotification(notification: Notification) {
guard
let attachmentId = notification.userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] as? Attachment.IDType
else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard attachmentId == self.attachmentId else {
return
}
let progress = notification.userInfo?[AttachmentDownloads.attachmentDownloadProgressKey] as? CGFloat
if progress == nil {
Logger.warn("No progress for attachment progress notification.")
}
updateDownloadProgress(progress)
}
private func updateDownloadProgress(_ progress: CGFloat?) {
AssertIsOnMainThread()
guard let progress else {
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.uploadAttachmentIDKey] as? Attachment.IDType 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.attachment.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: AttachmentStream) {
AssertIsOnMainThread()
if attachmentStream.attachment.isUploadedToTransitTier {
stateView.state = .uploadProgress(progress: 1)
} else {
stateView.state = .uploadUnknownProgress
}
}
public enum ProgressType {
case none
case uploading(attachmentStream: AttachmentStream)
case pendingDownload(attachmentPointer: AttachmentTransitPointer)
case downloading(attachmentPointer: AttachmentTransitPointer, transitTierDownloadState: AttachmentDownloadState)
case unknown
}
public static func progressType(
forAttachment attachment: CVAttachment,
interaction: TSInteraction
) -> ProgressType {
switch attachment {
case .backupThumbnail:
// TODO[Backups]: Update download state based on the media tier attachment state
return .none
case .stream(let attachmentStream):
if let outgoingMessage = interaction as? TSOutgoingMessage {
let hasSendFailed = outgoingMessage.messageState == .failed
let wasNotCreatedLocally = outgoingMessage.wasNotCreatedLocally
guard
!attachmentStream.attachment.isUploadedToTransitTier,
!attachmentStream.attachment.hasMediaTierInfo,
!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, .enqueuedOrDownloading:
return .downloading(
attachmentPointer: attachmentPointer.attachmentPointer,
transitTierDownloadState: transitTierDownloadState
)
}
}
}
}