From 5e0bfaadbf04c65e888091cbfce3c0d5027d95f4 Mon Sep 17 00:00:00 2001 From: Pete Walters Date: Mon, 11 May 2026 17:15:04 -0500 Subject: [PATCH] Update MediaGallery to tolerate undownloaded attachments --- .../CellViews/AudioMessageView.swift | 14 ++--- .../Components/CVComponentBodyMedia.swift | 16 +++--- .../Components/CVComponentDelegate.swift | 2 +- ...onViewController+CVComponentDelegate.swift | 4 +- .../ConversationViewController+OWS.swift | 2 +- .../EditHistoryTableSheetViewController.swift | 2 +- .../MediaGallery/Cells/AudioCell.swift | 35 +++++++----- .../Cells/MediaGalleryFileCell.swift | 42 +++++++++------ .../MediaGallery/MediaGallery.swift | 54 +++++++++---------- .../MediaGallery/MediaGalleryCellItem.swift | 24 ++++----- .../MediaItemViewController.swift | 50 +++++++++-------- .../MediaPageViewController.swift | 17 ++++-- .../MediaTileViewController.swift | 16 +++--- .../MediaPresentationContext.swift | 2 +- .../MediaGallery/VideoPlaybackControls.swift | 5 +- .../MemberLabelViewController.swift | 2 +- .../MessageDetailViewController.swift | 6 +-- .../PinnedMessagesDetailsViewController.swift | 2 +- ...ationSettingsViewController+Contents.swift | 4 +- .../ConversationSettingsViewController.swift | 21 +++----- Signal/src/views/MockConversationView.swift | 2 +- .../ReferencedAttachment.swift | 27 +++++++--- .../MediaGalleryAttachmentFinder.swift | 2 +- 23 files changed, 195 insertions(+), 156 deletions(-) diff --git a/Signal/ConversationView/CellViews/AudioMessageView.swift b/Signal/ConversationView/CellViews/AudioMessageView.swift index c41b6ba1e3..3dbf9229ba 100644 --- a/Signal/ConversationView/CellViews/AudioMessageView.swift +++ b/Signal/ConversationView/CellViews/AudioMessageView.swift @@ -519,20 +519,14 @@ extension AudioAttachment { case .attachmentStream(let stream, _): return ByteCountFormatter().string(for: stream.attachmentStream.unencryptedByteCount) ?? "" case .attachmentPointer: - owsFailDebug("Shouldn't get here - undownloaded media not implemented") + // TODO: [Media Gallery]: Source byte information for undownloaded attachment return "" } } var dateString: String { - switch state { - case .attachmentStream: - let dateFormatter = DateFormatter() - dateFormatter.setLocalizedDateFormatFromTemplate("Mdyy") - return dateFormatter.string(from: receivedAtDate) - case .attachmentPointer: - owsFailDebug("Shouldn't get here - undownloaded media not implemented") - return "" - } + let dateFormatter = DateFormatter() + dateFormatter.setLocalizedDateFormatFromTemplate("Mdyy") + return dateFormatter.string(from: receivedAtDate) } } diff --git a/Signal/ConversationView/Components/CVComponentBodyMedia.swift b/Signal/ConversationView/Components/CVComponentBodyMedia.swift index 6face7c9f7..ce17decfe9 100644 --- a/Signal/ConversationView/Components/CVComponentBodyMedia.swift +++ b/Signal/ConversationView/Components/CVComponentBodyMedia.swift @@ -437,21 +437,25 @@ class CVComponentBodyMedia: CVComponentBase, CVComponent { componentDelegate.didCancelDownload(message, attachmentId: pointer.attachment.id) return true } - case .stream(let stream, isUploading: _, imageMetadata: _): + case .stream(let referencedAttachmentStream, isUploading: _, imageMetadata: _): let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) - if let item = items.first(where: { $0.attachment.attachment.attachment.id == stream.attachment.id }), item.isBroken { + if let item = items.first(where: { $0.attachment.attachment.attachment.id == referencedAttachmentStream.attachment.id }), item.isBroken { componentDelegate.didTapBrokenVideo() return true } componentDelegate.didTapBodyMedia( itemViewModel: itemViewModel, - attachmentStream: stream, + attachment: referencedAttachmentStream, imageView: mediaView, ) return true - case .backupThumbnail: - // Download the fullsize attachment - componentDelegate.didTapSkippedDownloads(message) + case .backupThumbnail(let thumbnail): + let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) + componentDelegate.didTapBodyMedia( + itemViewModel: itemViewModel, + attachment: thumbnail, + imageView: mediaView, + ) return true case .undownloadable: componentDelegate.didTapUndownloadableMedia() diff --git a/Signal/ConversationView/Components/CVComponentDelegate.swift b/Signal/ConversationView/Components/CVComponentDelegate.swift index 69d2b793a9..0189ff6ba1 100644 --- a/Signal/ConversationView/Components/CVComponentDelegate.swift +++ b/Signal/ConversationView/Components/CVComponentDelegate.swift @@ -134,7 +134,7 @@ public protocol CVComponentDelegate: AnyObject, AudioMessageViewDelegate, CVPoll func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) diff --git a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift index 52e938a6f7..c0ba633396 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -483,7 +483,7 @@ extension ConversationViewController: CVComponentDelegate { public func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) { AssertIsOnMainThread() @@ -492,7 +492,7 @@ extension ConversationViewController: CVComponentDelegate { guard let pageVC = MediaPageViewController( - initialMediaAttachment: attachmentStream, + initialMediaAttachment: attachment, thread: self.thread, spoilerState: self.viewState.spoilerState, ) diff --git a/Signal/ConversationView/ConversationViewController+OWS.swift b/Signal/ConversationView/ConversationViewController+OWS.swift index d8c1aee040..eaf761a165 100644 --- a/Signal/ConversationView/ConversationViewController+OWS.swift +++ b/Signal/ConversationView/ConversationViewController+OWS.swift @@ -333,7 +333,7 @@ extension ConversationViewController: MediaPresentationContextProvider { return nil } - guard let mediaView = messageCell.albumItemView(forAttachment: galleryItem.attachmentStream) else { + guard let mediaView = messageCell.albumItemView(forAttachment: galleryItem.referencedAttachment) else { owsFailDebug("itemView was unexpectedly nil") return nil } diff --git a/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift b/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift index b4f7a3cbd9..e76b17e7f0 100644 --- a/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift +++ b/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift @@ -404,7 +404,7 @@ extension EditHistoryTableSheetViewController: CVComponentDelegate { func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) {} diff --git a/Signal/src/ViewControllers/MediaGallery/Cells/AudioCell.swift b/Signal/src/ViewControllers/MediaGallery/Cells/AudioCell.swift index 3e1f4f85c4..17449f7485 100644 --- a/Signal/src/ViewControllers/MediaGallery/Cells/AudioCell.swift +++ b/Signal/src/ViewControllers/MediaGallery/Cells/AudioCell.swift @@ -14,16 +14,9 @@ class AudioCell: MediaTileListModeCell { private var audioItem: MediaGalleryCellItemAudio? { didSet { - guard let audioItem else { + if audioItem == nil { audioAttachment = nil - return } - audioAttachment = AudioAttachment( - attachmentStream: audioItem.attachmentStream, - owningMessage: audioItem.message, - metadata: audioItem.metadata, - receivedAtDate: audioItem.receivedAtDate, - ) } } @@ -45,7 +38,7 @@ class AudioCell: MediaTileListModeCell { let currentContentSizeCategory = UITraitCollection.current.preferredContentSizeCategory let displaysTopLabel = AudioAllMediaPresenter.hasAttachmentLabel( - attachment: audioItem.attachmentStream.attachment, + attachment: audioItem.referencedAttachment.attachment, isVoiceMessage: audioItem.isVoiceMessage, ) @@ -60,8 +53,9 @@ class AudioCell: MediaTileListModeCell { } guard + let stream = audioItem.referencedAttachment.asReferencedStream, let audioAttachment = AudioAttachment( - attachmentStream: audioItem.attachmentStream, + attachmentStream: stream, owningMessage: audioItem.message, metadata: audioItem.metadata, receivedAtDate: audioItem.receivedAtDate, @@ -266,7 +260,7 @@ class AudioCell: MediaTileListModeCell { let cvAudioPlayer = AppEnvironment.shared.cvAudioPlayerRef cvAudioPlayer.setPlaybackProgress( progress: scrubbedTime, - forAttachment: audioItem.attachmentStream.attachment, + forAttachment: audioItem.referencedAttachment.attachment, ) case .possible, .failed, .cancelled: audioMessageView.clearOverrideProgress(animated: false) @@ -283,6 +277,7 @@ class AudioCell: MediaTileListModeCell { guard let itemModel, let audioMessageView, let audioItem, let audioAttachment else { return } + guard case .attachmentStream = audioAttachment.state else { return } if audioMessageView.handleTap(sender: sender, itemModel: itemModel) { return } @@ -317,7 +312,6 @@ class AudioCell: MediaTileListModeCell { owsFailDebug("Unexpected item type") return } - self.audioItem = audioItem self.spoilerState = spoilerState if let audioMessageView { @@ -325,7 +319,24 @@ class AudioCell: MediaTileListModeCell { self.audioMessageView = nil } + self.audioItem = audioItem SSKEnvironment.shared.databaseStorageRef.read { transaction in + if let referencedStream = audioItem.referencedAttachment.asReferencedStream { + audioAttachment = AudioAttachment( + attachmentStream: referencedStream, + owningMessage: audioItem.message, + metadata: audioItem.metadata, + receivedAtDate: audioItem.receivedAtDate, + ) + } else if let pointer = audioItem.referencedAttachment.asReferencedAnyPointer { + audioAttachment = AudioAttachment( + attachmentPointer: pointer, + owningMessage: audioItem.message, + metadata: audioItem.metadata, + receivedAtDate: audioItem.receivedAtDate, + downloadState: pointer.attachmentPointer.downloadState(tx: transaction), + ) + } createAudioMessageView(transaction: transaction) } } diff --git a/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift b/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift index 0bf46bdc9c..2173b748bf 100644 --- a/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift +++ b/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift @@ -12,7 +12,7 @@ class MediaGalleryFileCell: MediaTileListModeCell { static let reuseIdentifier = "MediaGalleryFileCell" - private var attachment: ReferencedAttachmentStream? + private var referencedAttachment: ReferencedAttachment? private var receivedAtDate: Date? private var owningMessage: TSMessage? private var mediaMetadata: MediaMetadata? @@ -20,13 +20,13 @@ class MediaGalleryFileCell: MediaTileListModeCell { private var fileItem: MediaGalleryCellItemOtherFile? { didSet { guard let fileItem else { - attachment = nil + referencedAttachment = nil receivedAtDate = nil owningMessage = nil mediaMetadata = nil return } - attachment = fileItem.attachmentStream + referencedAttachment = fileItem.referencedAttachment receivedAtDate = fileItem.receivedAtDate owningMessage = fileItem.message mediaMetadata = fileItem.metadata @@ -58,7 +58,7 @@ class MediaGalleryFileCell: MediaTileListModeCell { return cellHeight } - guard let attachment = item.attachmentStream else { + guard let attachment = item.referencedAttachment?.asReferencedStream else { return defaultCellHeight } let genericAttachment = CVComponentState.GenericAttachment( @@ -86,6 +86,7 @@ class MediaGalleryFileCell: MediaTileListModeCell { private var itemModel: CVItemModel? private var genericAttachmentView: CVComponentView? + private var attachmentType: CVAttachment? private let genericAttachmentContainerView: UIView = { let view = UIView.container() @@ -157,11 +158,21 @@ class MediaGalleryFileCell: MediaTileListModeCell { itemViewState: itemViewState.build(), coreState: coreState, ) - let genericAttachment = CVComponentState.GenericAttachment(attachment: .stream( - fileItem.attachmentStream, - isUploading: false, - imageMetadata: nil, - )) + + self.attachmentType = { + if let stream = fileItem.referencedAttachment.asReferencedStream { + return .stream(stream, isUploading: false, imageMetadata: nil) + } else if let backupPointer = fileItem.referencedAttachment.asReferencedBackupThumbnail { + return .backupThumbnail(backupPointer) + } else if let pointer = fileItem.referencedAttachment.asReferencedAnyPointer { + let downloadState = pointer.attachmentPointer.downloadState(tx: transaction) + return .pointer(pointer, downloadState: downloadState) + } + return nil + }() + guard let attachmentType else { return } + + let genericAttachment = CVComponentState.GenericAttachment(attachment: attachmentType) let component = CVComponentGenericAttachment( itemModel: itemModel, genericAttachment: genericAttachment, @@ -222,16 +233,15 @@ class MediaGalleryFileCell: MediaTileListModeCell { @objc private func handleTapGesture(_ sender: UITapGestureRecognizer) { - guard let fileItem, let itemModel else { + guard + let itemModel, + let attachmentType + else { return } let genericAttachment = CVComponentGenericAttachment( itemModel: itemModel, - genericAttachment: .init(attachment: .stream( - fileItem.attachmentStream, - isUploading: false, - imageMetadata: nil, - )), + genericAttachment: .init(attachment: attachmentType), ) if PKAddPassesViewController.canAddPasses(), @@ -419,7 +429,7 @@ extension MediaGalleryFileCell: CVComponentDelegate { func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) {} diff --git a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift index 92b07cda6b..058b1f3c82 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift @@ -40,10 +40,10 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { let message: TSMessage let sender: Sender? - let attachmentStream: ReferencedAttachmentStream + let referencedAttachment: ReferencedAttachment let receivedAtDate: Date - var renderingFlag: AttachmentReference.RenderingFlag { attachmentStream.reference.renderingFlag } + var renderingFlag: AttachmentReference.RenderingFlag { referencedAttachment.reference.renderingFlag } let galleryDate: GalleryDate let captionForDisplay: MediaCaptionView.Content? @@ -54,7 +54,7 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { init( message: TSMessage, sender: Sender?, - attachmentStream: ReferencedAttachmentStream, + referencedAttachment: ReferencedAttachment, albumIndex: Int, numItemsInAlbum: Int, spoilerState: SpoilerRenderState, @@ -62,13 +62,13 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { ) { self.message = message self.sender = sender - self.attachmentStream = attachmentStream + self.referencedAttachment = referencedAttachment self.receivedAtDate = message.receivedAtDate self.galleryDate = GalleryDate(message: message) self.albumIndex = albumIndex self.numItemsInAlbum = numItemsInAlbum self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex) - if let captionText = attachmentStream.reference.legacyMessageCaption?.filterForDisplay { + if let captionText = referencedAttachment.reference.legacyMessageCaption?.filterForDisplay { self.captionForDisplay = .attachmentStreamCaption(captionText) } else if let body = message.body { let hydratedMessageBody = MessageBody( @@ -83,11 +83,15 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { } } - private var mimeType: String { attachmentStream.attachmentStream.mimeType } + private var mimeType: String { referencedAttachment.attachment.mimeType } var isVideo: Bool { - switch attachmentStream.attachmentStream.contentType { + switch referencedAttachment.attachment.contentType { case .video: + // For now, if the video isn't a stream, don't treat it as a video. + if referencedAttachment.asReferencedStream == nil { + return false + } return renderingFlag != .shouldLoop case .file, .image, .audio: return false @@ -95,7 +99,7 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { } var isAnimated: Bool { - switch attachmentStream.attachmentStream.contentType { + switch referencedAttachment.attachment.contentType { case .video: return renderingFlag == .shouldLoop case .file, .image, .audio: @@ -104,7 +108,7 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { } var isImage: Bool { - switch attachmentStream.attachmentStream.contentType { + switch referencedAttachment.attachment.contentType { case .image: return true case .file, .video, .audio: @@ -112,33 +116,33 @@ struct MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { } } - var attachmentId: AttachmentReferenceId { attachmentStream.reference.referenceId } + var attachmentId: AttachmentReferenceId { referencedAttachment.reference.referenceId } typealias AsyncThumbnailBlock = @MainActor (UIImage) -> Void func thumbnailImage(completion: @escaping AsyncThumbnailBlock) { - Task { [attachmentStream] in - if let image = await attachmentStream.attachmentStream.thumbnailImage(quality: .small) { + Task { [referencedAttachment] in + if let image = await referencedAttachment.getThumbnailImage(quality: .small) { await completion(image) } } } func thumbnailImageSync() -> UIImage? { - return attachmentStream.attachmentStream.thumbnailImageSync(quality: .small) + referencedAttachment.getThumbnailImageSync(quality: .small) } // MARK: Equatable static func ==(lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool { - return lhs.attachmentStream.attachmentStream.id == rhs.attachmentStream.attachmentStream.id - && lhs.attachmentStream.reference.hasSameOwner(as: rhs.attachmentStream.reference) + return lhs.referencedAttachment.attachment.id == rhs.referencedAttachment.attachment.id + && lhs.referencedAttachment.reference.hasSameOwner(as: rhs.referencedAttachment.reference) } // MARK: Hashable func hash(into hasher: inout Hasher) { - hasher.combine(attachmentStream.attachmentStream.id) - let attachmentReference = attachmentStream.reference + hasher.combine(referencedAttachment.attachment.id) + let attachmentReference = referencedAttachment.reference hasher.combine(attachmentReference.owner.id) } @@ -484,10 +488,6 @@ class MediaGallery { spoilerState: SpoilerRenderState, transaction: DBReadTransaction, ) -> MediaGalleryItem? { - guard let attachmentStream = attachment.attachment.asStream() else { - owsFailDebug("gallery doesn't yet support showing undownloaded attachments") - return nil - } let message: TSMessage switch attachment.reference.owner { @@ -560,7 +560,7 @@ class MediaGallery { return MediaGalleryItem( message: message, sender: sender, - attachmentStream: .init(reference: attachment.reference, attachmentStream: attachmentStream), + referencedAttachment: .init(reference: attachment.reference, attachment: attachment.attachment), albumIndex: Int(albumIndex), numItemsInAlbum: itemsInAlbum.count, spoilerState: spoilerState, @@ -674,7 +674,7 @@ class MediaGallery { shouldLoadAlbumRemainder: Bool, ) { guard let path = indexPath(for: item) else { - owsFailDebug("showing detail view for an item that hasn't been loaded: \(item.attachmentStream)") + owsFailDebug("showing detail view for an item that hasn't been loaded: \(item.referencedAttachment)") return } @@ -702,7 +702,7 @@ class MediaGallery { guard let itemId = mediaGalleryFinder.galleryItemId( - of: focusedItem.attachmentStream, + of: focusedItem.referencedAttachment, in: focusedItem.galleryDate.interval, excluding: deletedAttachmentIds, tx: transaction, @@ -839,13 +839,13 @@ class MediaGallery { return } - Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })") + Logger.info("with items: \(items.map { ($0.referencedAttachment, $0.message.timestamp) })") deletedGalleryItems.formUnion(items) delegates.forEach { $0.mediaGallery(self, willDelete: items, initiatedBy: initiatedBy) } deletedAttachmentIds.formUnion(items.lazy.map { - $0.attachmentStream.reference.referenceId + $0.referencedAttachment.reference.referenceId }) Task { @@ -858,7 +858,7 @@ class MediaGallery { for item in items { let message = item.message - let referencedAttachment: ReferencedAttachment = item.attachmentStream + let referencedAttachment: ReferencedAttachment = item.referencedAttachment attachmentStore.removeReference( reference: referencedAttachment.reference, diff --git a/Signal/src/ViewControllers/MediaGallery/MediaGalleryCellItem.swift b/Signal/src/ViewControllers/MediaGallery/MediaGalleryCellItem.swift index 1ee701983d..a8a5fb2a58 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaGalleryCellItem.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaGalleryCellItem.swift @@ -22,14 +22,14 @@ enum MediaGalleryCellItem { case audio(MediaGalleryCellItemAudio) case otherFile(MediaGalleryCellItemOtherFile) - var attachmentStream: ReferencedAttachmentStream? { + var referencedAttachment: ReferencedAttachment? { switch self { case .photoVideo(let item): - return item.galleryItem.attachmentStream + return item.galleryItem.referencedAttachment case .audio(let audioItem): - return audioItem.attachmentStream + return audioItem.referencedAttachment case .otherFile(let fileItem): - return fileItem.attachmentStream + return fileItem.referencedAttachment } } } @@ -40,9 +40,9 @@ extension MediaGalleryCellItem: Equatable { case let (.photoVideo(lvalue), .photoVideo(rvalue)): return lvalue === rvalue case let (.audio(lvalue), .audio(rvalue)): - return lvalue.attachmentStream.reference.attachmentRowId == rvalue.attachmentStream.reference.attachmentRowId + return lvalue.referencedAttachment.reference.attachmentRowId == rvalue.referencedAttachment.reference.attachmentRowId case let (.otherFile(lvalue), .otherFile(rvalue)): - return lvalue.attachmentStream.reference.attachmentRowId == rvalue.attachmentStream.reference.attachmentRowId + return lvalue.referencedAttachment.reference.attachmentRowId == rvalue.referencedAttachment.reference.attachmentRowId case (.photoVideo, _), (.audio, _), (.otherFile, _): return false } @@ -53,7 +53,7 @@ struct MediaGalleryCellItemAudio { var message: TSMessage var interaction: TSInteraction var thread: TSThread - var attachmentStream: ReferencedAttachmentStream + var referencedAttachment: ReferencedAttachment var receivedAtDate: Date var isVoiceMessage: Bool var mediaCache: CVMediaCache @@ -78,13 +78,13 @@ struct MediaGalleryCellItemOtherFile { var message: TSMessage var interaction: TSInteraction var thread: TSThread - var attachmentStream: ReferencedAttachmentStream + var referencedAttachment: ReferencedAttachment var receivedAtDate: Date var mediaCache: CVMediaCache var metadata: MediaMetadata - var size: UInt { - UInt(attachmentStream.attachmentStream.unencryptedByteCount) + var size: UInt64 { + referencedAttachment.unencryptedByteCount() ?? 0 } var localizedString: String { @@ -105,7 +105,7 @@ class MediaGalleryCellItemPhotoVideo: PhotoGridItem { var type: PhotoGridItemType { if galleryItem.isVideo { return .video( - duration: galleryItem.attachmentStream.attachmentStream.cachedVideoDuration, + duration: galleryItem.referencedAttachment.asReferencedStream?.attachmentStream.cachedVideoDuration, ) } else if galleryItem.isAnimated { return .animated @@ -128,7 +128,7 @@ extension MediaGalleryItem { return MediaMetadata( sender: sender?.name ?? "", abbreviatedSender: sender?.abbreviatedName ?? "", - byteSize: Int(attachmentStream.attachmentStream.unencryptedByteCount), + byteSize: Int(clamping: referencedAttachment.unencryptedByteCount() ?? 0), creationDate: receivedAtDate, ) } diff --git a/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift b/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift index b00120f872..71129fe0f2 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift @@ -33,7 +33,7 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { super.init() - image = attachmentStream.thumbnailImageSync(quality: .large) + image = galleryItem.referencedAttachment.getThumbnailImageSync(quality: .large) } deinit { @@ -116,37 +116,43 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { guard mediaView == nil else { return } let view: UIView - if attachmentStream.contentType.isVideo, galleryItem.renderingFlag == .shouldLoop { - if let loopingVideoPlayerView = buildLoopingVideoPlayerView() { + let referencedAttachmentStream = galleryItem.referencedAttachment.asReferencedStream + if + let referencedAttachmentStream, + referencedAttachmentStream.attachmentStream.contentType.isVideo, + galleryItem.renderingFlag == .shouldLoop + { + if let loopingVideoPlayerView = buildLoopingVideoPlayerView(attachmentStream: referencedAttachmentStream.attachmentStream) { loopingVideoPlayerView.delegate = self view = loopingVideoPlayerView } else { view = buildPlaceholderView() } } else if - let imageMetadata = attachmentStream.imageMetadata(), + let referencedAttachmentStream, + let imageMetadata = referencedAttachmentStream.attachmentStream.imageMetadata(), imageMetadata.isAnimated { - if let animatedGif = try? attachmentStream.decryptedSDAnimatedImage() { + if let animatedGif = try? referencedAttachmentStream.attachmentStream.decryptedSDAnimatedImage() { view = SDAnimatedImageView(image: animatedGif) } else { view = buildPlaceholderView() } - } else if image == nil { - // Still loading thumbnail. - view = buildPlaceholderView() - } else if isVideo { - if attachmentStream.contentType.isVideo, let videoPlayerView = buildVideoPlayerView() { - videoPlayerView.delegate = self - videoPlayerView.videoPlayer?.delegate = self + } else if + let referencedAttachmentStream, + isVideo, // TODO: Separate isVideo from isVideoPlayable + let videoPlayerView = buildVideoPlayerView(referencedAttachmentStream: referencedAttachmentStream) + { + videoPlayerView.delegate = self + videoPlayerView.videoPlayer?.delegate = self - view = videoPlayerView - } else { - view = buildPlaceholderView() - } - } else { + view = videoPlayerView + } else if let image { // Present the static image using standard UIImageView view = UIImageView(image: image) + } else { + // Still loading thumbnail. + view = buildPlaceholderView() } mediaView = view @@ -158,7 +164,7 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { return view } - private func buildLoopingVideoPlayerView() -> LoopingVideoView? { + private func buildLoopingVideoPlayerView(attachmentStream: AttachmentStream) -> LoopingVideoView? { guard let loopingVideo = LoopingVideo(attachmentStream) else { owsFailBeta("Invalid looping video") return nil @@ -168,8 +174,8 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { return videoView } - private func buildVideoPlayerView() -> VideoPlayerView? { - guard let videoPlayer = try? VideoPlayer(attachment: galleryItem.attachmentStream) else { + private func buildVideoPlayerView(referencedAttachmentStream: ReferencedAttachmentStream) -> VideoPlayerView? { + guard let videoPlayer = try? VideoPlayer(attachment: referencedAttachmentStream) else { owsFailBeta("Invalid attachment") return nil } @@ -224,7 +230,7 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { } let timestamp = Date().ows_millisecondsSince1970 - let attachmentId = galleryItem.attachmentStream.attachment.id + let attachmentId = galleryItem.referencedAttachment.attachment.id Task { await DependenciesBridge.shared.db.awaitableWrite { tx in DependenciesBridge.shared.attachmentStore.markViewedFullscreen( @@ -246,8 +252,6 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { private var image: UIImage? - private var attachmentStream: AttachmentStream { galleryItem.attachmentStream.attachmentStream } - // MARK: - Video Playback var shouldAutoPlayVideo: Bool = false diff --git a/Signal/src/ViewControllers/MediaGallery/MediaPageViewController.swift b/Signal/src/ViewControllers/MediaGallery/MediaPageViewController.swift index 80fb453c31..33ea60e587 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaPageViewController.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaPageViewController.swift @@ -547,8 +547,13 @@ class MediaPageViewController: UIPageViewController { private func shareCurrentMedia(fromNavigationBar: Bool) { guard let currentViewController else { return } + guard let stream = currentViewController.galleryItem.referencedAttachment.asReferencedStream else { + // TODO: [MediaGallery]: Handle undownloaded media + owsFailDebug("Cannot share undownloaded media") + return + } guard - let attachmentStream = (try? [currentViewController.galleryItem.attachmentStream].asShareableAttachments())?.first + let attachmentStream = (try? [stream].asShareableAttachments())?.first else { return } @@ -560,9 +565,13 @@ class MediaPageViewController: UIPageViewController { private func saveCurrentMediaToPhotos() { guard let mediaItem = currentItem else { return } - + guard let stream = mediaItem.referencedAttachment.asReferencedStream else { + // TODO: [MediaGallery]: Handle undownloaded media + owsFailDebug("Cannot share undownloaded media") + return + } AttachmentSaving.saveToPhotoLibrary( - referencedAttachmentStreams: [mediaItem.attachmentStream], + referencedAttachmentStreams: [stream], ) } @@ -835,7 +844,7 @@ extension MediaPageViewController: MediaGalleryDelegate { } func didReloadAllSectionsInMediaGallery(_ mediaGallery: MediaGallery) { - let attachment = currentItem.attachmentStream + let attachment = currentItem.referencedAttachment guard let reloadedItem = mediaGallery.ensureLoadedForDetailView(focusedAttachment: attachment) else { // Assume the item was deleted. dismissSelf(animated: true) diff --git a/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift b/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift index 74ef19da61..0dcf72a4e5 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift @@ -613,7 +613,7 @@ class MediaTileViewController: UICollectionViewController, MediaGalleryDelegate, guard let pageVC = MediaPageViewController( - initialMediaAttachment: galleryItem.attachmentStream, + initialMediaAttachment: galleryItem.referencedAttachment, mediaGallery: mediaGallery, spoilerState: spoilerState, ) @@ -804,7 +804,7 @@ class MediaTileViewController: UICollectionViewController, MediaGalleryDelegate, message: galleryItem.message, interaction: galleryItem.message, thread: thread, - attachmentStream: galleryItem.attachmentStream, + referencedAttachment: galleryItem.referencedAttachment, receivedAtDate: galleryItem.receivedAtDate, isVoiceMessage: galleryItem.renderingFlag == .voiceMessage, mediaCache: mediaCache, @@ -815,7 +815,7 @@ class MediaTileViewController: UICollectionViewController, MediaGalleryDelegate, message: galleryItem.message, interaction: galleryItem.message, thread: thread, - attachmentStream: galleryItem.attachmentStream, + referencedAttachment: galleryItem.referencedAttachment, receivedAtDate: galleryItem.receivedAtDate, mediaCache: mediaCache, metadata: galleryItem.mediaMetadata!, @@ -1521,7 +1521,7 @@ extension MediaTileViewController: MediaGalleryPrimaryViewController { } let totalSize = items.reduce(UInt64(0), { result, item in - result + UInt64(safeCast: item.attachmentStream.attachmentStream.unencryptedByteCount) + result + (item.referencedAttachment.unencryptedByteCount() ?? 0) }) return (items.count, totalSize) } @@ -1737,8 +1737,12 @@ extension MediaTileViewController: MediaGalleryPrimaryViewController { return } - let attachments = indexPaths.compactMap { - self.galleryItem(at: $0)?.attachmentStream + let attachments: [ReferencedAttachmentStream] = indexPaths.compactMap { + self.galleryItem(at: $0)?.referencedAttachment.asReferencedStream + } + if attachments.count < indexPaths.count { + // TODO: [MediaGallery] Better handling of undownloaded attachmets + owsFailDebug("Attempting to share undownloaded attachments") } let items: [ShareableAttachment] = (try? attachments.asShareableAttachments()) ?? [] guard items.count == indexPaths.count else { diff --git a/Signal/src/ViewControllers/MediaGallery/Transitions/MediaPresentationContext.swift b/Signal/src/ViewControllers/MediaGallery/Transitions/MediaPresentationContext.swift index e0cae48add..2842e47412 100644 --- a/Signal/src/ViewControllers/MediaGallery/Transitions/MediaPresentationContext.swift +++ b/Signal/src/ViewControllers/MediaGallery/Transitions/MediaPresentationContext.swift @@ -12,7 +12,7 @@ enum Media { var image: UIImage? { switch self { case let .gallery(item): - return try? item.attachmentStream.attachmentStream.decryptedImage() + return item.referencedAttachment.getBestAvailableLocalImage() case let .image(image): return image } diff --git a/Signal/src/ViewControllers/MediaGallery/VideoPlaybackControls.swift b/Signal/src/ViewControllers/MediaGallery/VideoPlaybackControls.swift index ae64e9fa86..3ff2aff3a8 100644 --- a/Signal/src/ViewControllers/MediaGallery/VideoPlaybackControls.swift +++ b/Signal/src/ViewControllers/MediaGallery/VideoPlaybackControls.swift @@ -173,11 +173,10 @@ class VideoPlaybackControlView: UIView { weak var delegate: VideoPlaybackControlViewDelegate? func updateWithMediaItem(_ mediaItem: MediaGalleryItem) { - let attachmentStream = mediaItem.attachmentStream.attachmentStream - switch attachmentStream.contentType { + switch mediaItem.referencedAttachment.attachment.contentType { case .video: if - let videoDuration = attachmentStream.cachedVideoDuration, + let videoDuration = mediaItem.referencedAttachment.asReferencedStream?.attachmentStream.cachedVideoDuration, videoDuration > 30 { showRewindAndFastForward = true diff --git a/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift b/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift index 9fe5f2bbd3..3e6bfa2b85 100644 --- a/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift +++ b/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift @@ -685,7 +685,7 @@ extension MemberLabelViewController: CVComponentDelegate { func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) {} diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 840ea3ce47..8188cab6d1 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -820,7 +820,7 @@ extension MessageDetailViewController: MediaPresentationContextProvider { return nil } - guard let mediaView = cellView.albumItemView(forAttachment: galleryItem.attachmentStream) else { + guard let mediaView = cellView.albumItemView(forAttachment: galleryItem.referencedAttachment) else { owsFailDebug("itemView was unexpectedly nil") return nil } @@ -1160,7 +1160,7 @@ extension MessageDetailViewController: CVComponentDelegate { func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) { guard let thread else { @@ -1169,7 +1169,7 @@ extension MessageDetailViewController: CVComponentDelegate { } guard let mediaPageVC = MediaPageViewController( - initialMediaAttachment: attachmentStream, + initialMediaAttachment: attachment, thread: thread, spoilerState: self.spoilerState, showingSingleMessage: true, diff --git a/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift b/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift index 614f03c69a..c9e49a84e6 100644 --- a/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift +++ b/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift @@ -530,7 +530,7 @@ extension PinnedMessagesDetailsViewController: CVComponentDelegate { func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) {} diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift index 80b7a14ab2..bd34ed755b 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift @@ -275,9 +275,9 @@ extension ConversationSettingsViewController { let availableWidth = self.view.width - ((Self.cellHInnerMargin * 2) + self.cellOuterInsets.totalWidth + self.view.safeAreaInsets.totalWidth) let imageWidth = (availableWidth - totalSpacerSize) / CGFloat(self.maximumRecentMedia) - for (attachmentStream, imageView) in self.recentMedia.orderedValues { + for (referencedAttachment, imageView) in self.recentMedia.orderedValues { let button = OWSButton { [weak self] in - self?.showMediaPageView(for: attachmentStream) + self?.showMediaPageView(for: referencedAttachment) } stackView.addArrangedSubview(button) button.autoSetDimensions(to: CGSize(square: imageWidth)) diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index ca312e353a..a3b7f0a3bf 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -860,10 +860,10 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti navigationController?.pushViewController(tileVC, animated: true) } - func showMediaPageView(for attachmentStream: ReferencedAttachmentStream) { + func showMediaPageView(for referencedAttachment: ReferencedAttachment) { guard let vc = MediaPageViewController( - initialMediaAttachment: attachmentStream, + initialMediaAttachment: referencedAttachment, thread: thread, spoilerState: spoilerState, ) @@ -879,7 +879,7 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti let maximumRecentMedia = 4 private(set) var recentMedia = OrderedDictionary< AttachmentReferenceId, - (attachment: ReferencedAttachmentStream, imageView: UIImageView), + (attachment: ReferencedAttachment, imageView: UIImageView), >() { didSet { AssertIsOnMainThread() } } @@ -894,10 +894,6 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti mediaGalleryFinder.recentMediaAttachments(limit: maximumRecentMedia, tx: transaction) } recentMedia = recentAttachments.reduce(into: OrderedDictionary(), { result, attachment in - guard let attachmentStream = attachment.asReferencedStream else { - return owsFailDebug("Unexpected type of attachment") - } - let imageView = UIImageView() imageView.clipsToBounds = true if #available(iOS 26, *) { @@ -907,14 +903,11 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti imageView.layer.cornerRadius = 4 } imageView.contentMode = .scaleAspectFill - - Task { - imageView.image = await attachmentStream.attachmentStream.thumbnailImage(quality: .small) - } + imageView.image = attachment.getThumbnailImageSync(quality: .small) result.append( - key: attachmentStream.reference.referenceId, - value: (attachmentStream, imageView), + key: attachment.reference.referenceId, + value: (attachment, imageView), ) }) shouldRefreshAttachmentsOnReappear = false @@ -1093,7 +1086,7 @@ extension ConversationSettingsViewController: MediaPresentationContextProvider { let mediaViewShape: MediaViewShape switch item { case .gallery(let galleryItem): - guard let imageView = recentMedia[galleryItem.attachmentStream.reference.referenceId]?.imageView else { return nil } + guard let imageView = recentMedia[galleryItem.referencedAttachment.reference.referenceId]?.imageView else { return nil } mediaView = imageView mediaViewShape = .rectangle(imageView.layer.cornerRadius) case .image: diff --git a/Signal/src/views/MockConversationView.swift b/Signal/src/views/MockConversationView.swift index c799811c2b..32409c39bf 100644 --- a/Signal/src/views/MockConversationView.swift +++ b/Signal/src/views/MockConversationView.swift @@ -422,7 +422,7 @@ extension MockConversationView: CVComponentDelegate { func didTapBodyMedia( itemViewModel: CVItemViewModelImpl, - attachmentStream: ReferencedAttachmentStream, + attachment: ReferencedAttachment, imageView: UIView, ) {} diff --git a/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/ReferencedAttachment.swift b/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/ReferencedAttachment.swift index da3a11296d..4c3f7db513 100644 --- a/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/ReferencedAttachment.swift +++ b/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/ReferencedAttachment.swift @@ -7,7 +7,7 @@ import Foundation /// Just a simple structure holding an attachment and a reference to it, /// since that's something we need to do very often. -public class ReferencedAttachment { +public class ReferencedAttachment: CustomDebugStringConvertible { public let reference: AttachmentReference public let attachment: Attachment @@ -54,26 +54,32 @@ public class ReferencedAttachment { public func getThumbnailImage(quality: AttachmentThumbnailQuality) async -> UIImage? { if let stream = asReferencedStream?.attachmentStream { return await stream.thumbnailImage(quality: quality) - } else if let backupThumbnail = AttachmentBackupThumbnail(attachment: attachment) { - return backupThumbnail.image } - return nil + return getPlaceholderImage() } public func getThumbnailImageSync(quality: AttachmentThumbnailQuality) -> UIImage? { if let stream = asReferencedStream?.attachmentStream { return stream.thumbnailImageSync(quality: quality) - } else if let backupThumbnail = AttachmentBackupThumbnail(attachment: attachment) { - return backupThumbnail.image } - return nil + return getPlaceholderImage() } public func getBestAvailableLocalImage() -> UIImage? { if let stream = asReferencedStream?.attachmentStream { return try? stream.decryptedImage() - } else if let backupThumbnail = AttachmentBackupThumbnail(attachment: attachment) { + } + return getPlaceholderImage() + } + + private func getPlaceholderImage() -> UIImage? { + if let backupThumbnail = AttachmentBackupThumbnail(attachment: attachment) { return backupThumbnail.image + } else if + let blurHash = attachment.blurHash?.nilIfEmpty, + let blurHashImage = BlurHash.image(for: blurHash) + { + return blurHashImage } return nil } @@ -86,6 +92,11 @@ public class ReferencedAttachment { } return nil } + + public var debugDescription: String { + let isStream = self as? ReferencedAttachmentStream != nil + return "ReferencedAttachment(reference: \(reference.owner.id), attachment: \(attachment.id), downloaded: \(isStream))" + } } public class ReferencedAttachmentStream: ReferencedAttachment { diff --git a/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift b/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift index ef9ac42188..682e071afc 100644 --- a/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift +++ b/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift @@ -241,7 +241,7 @@ public struct MediaGalleryAttachmentFinder { // Disregards filter. public func galleryItemId( - of attachment: ReferencedAttachmentStream, + of attachment: ReferencedAttachment, in interval: DateInterval, excluding deletedAttachmentIds: Set, tx: DBReadTransaction,