// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit import LibSignalClient public class AttachmentMultisend { public struct Result { /// Resolved when the messages are prepared but before uploading/sending. public let preparedPromise: Promise<[PreparedOutgoingMessage]> /// Resolved when sending is durably enqueued but before uploading/sending. public let enqueuedPromise: Promise<[TSThread]> /// Resolved when the message is sent. public let sentPromise: Promise<[TSThread]> } private init() {} // MARK: - API public class func sendApprovedMedia( conversations: [ConversationItem], approvedMessageBody: MessageBody?, approvedAttachments: [SignalAttachment] ) -> AttachmentMultisend.Result { let (preparedPromise, preparedFuture) = Promise<[PreparedOutgoingMessage]>.pending() let (enqueuedPromise, enqueuedFuture) = Promise<[TSThread]>.pending() let sentPromise = Promise<[TSThread]>.wrapAsync { let threads: [TSThread] let preparedMessages: [PreparedOutgoingMessage] let sendPromises: [Promise] do { let destinations = try Self.prepareForSending( approvedMessageBody, to: conversations, db: deps.databaseStorage, attachmentValidator: deps.tsResourceValidator ) var hasNonStoryDestination = false var hasStoryDestination = false destinations.forEach { destination in switch destination.conversationItem.outgoingMessageType { case .message: hasNonStoryDestination = true case .storyMessage: hasStoryDestination = true } } let segmentedAttachments = try await segmentAttachmentsIfNecessary( for: conversations, approvedAttachments: approvedAttachments, hasNonStoryDestination: hasNonStoryDestination, hasStoryDestination: hasStoryDestination ) (threads, preparedMessages, sendPromises) = try await deps.databaseStorage.awaitableWrite { tx in let threads: [TSThread] let preparedMessages: [PreparedOutgoingMessage] (threads, preparedMessages) = try prepareForSending( destinations: destinations, // Stories get an untruncated message body messageBodyForStories: approvedMessageBody, approvedAttachments: segmentedAttachments, tx: tx ) let sendPromises: [Promise] = preparedMessages.map { deps.messageSenderJobQueue.add( .promise, message: $0, transaction: tx ) } return (threads, preparedMessages, sendPromises) } } catch let error { preparedFuture.reject(error) enqueuedFuture.reject(error) throw error } preparedFuture.resolve(preparedMessages) enqueuedFuture.resolve(threads) try await withThrowingTaskGroup(of: Void.self) { taskGroup in sendPromises.forEach { promise in taskGroup.addTask { try await promise.awaitable() } } try await taskGroup.waitForAll() } return threads } return .init( preparedPromise: preparedPromise, enqueuedPromise: enqueuedPromise, sentPromise: sentPromise ) } public class func sendTextAttachment( _ textAttachment: UnsentTextAttachment, to conversations: [ConversationItem] ) -> AttachmentMultisend.Result { let (preparedPromise, preparedFuture) = Promise<[PreparedOutgoingMessage]>.pending() let (enqueuedPromise, enqueuedFuture) = Promise<[TSThread]>.pending() let sentPromise = Promise<[TSThread]>.wrapAsync { // Prepare the text attachment let textAttachment = try textAttachment.validateAndPrepareForSending() let threads: [TSThread] let preparedMessages: [PreparedOutgoingMessage] let sendPromises: [Promise] do { (threads, preparedMessages, sendPromises) = try await deps.databaseStorage.awaitableWrite { tx in let threads: [TSThread] let preparedMessages: [PreparedOutgoingMessage] (threads, preparedMessages) = try prepareForSending( conversations: conversations, textAttachment, tx: tx ) let sendPromises: [Promise] = preparedMessages.map { deps.messageSenderJobQueue.add( .promise, message: $0, transaction: tx ) } return (threads, preparedMessages, sendPromises) } } catch let error { preparedFuture.reject(error) enqueuedFuture.reject(error) throw error } preparedFuture.resolve(preparedMessages) enqueuedFuture.resolve(threads) try await withThrowingTaskGroup(of: Void.self) { taskGroup in sendPromises.forEach { promise in taskGroup.addTask { try await promise.awaitable() } } try await taskGroup.waitForAll() } return threads } return .init( preparedPromise: preparedPromise, enqueuedPromise: enqueuedPromise, sentPromise: sentPromise ) } // MARK: - Dependencies private struct Dependencies { let attachmentManager: AttachmentManager let attachmentValidator: AttachmentContentValidator let contactsMentionHydrator: ContactsMentionHydrator.Type let databaseStorage: SDSDatabaseStorage let imageQualityLevel: ImageQualityLevel.Type let messageSenderJobQueue: MessageSenderJobQueue let tsAccountManager: TSAccountManager let tsResourceValidator: TSResourceContentValidator } private static var deps = Dependencies( attachmentManager: DependenciesBridge.shared.attachmentManager, attachmentValidator: DependenciesBridge.shared.attachmentContentValidator, contactsMentionHydrator: ContactsMentionHydrator.self, databaseStorage: SSKEnvironment.shared.databaseStorage, imageQualityLevel: ImageQualityLevel.self, messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef, tsAccountManager: DependenciesBridge.shared.tsAccountManager, tsResourceValidator: DependenciesBridge.shared.tsResourceContentValidator ) // MARK: - Segmenting Attachments private struct SegmentAttachmentResult { let original: AttachmentDataSource? let segmented: [AttachmentDataSource]? let isViewOnce: Bool let renderingFlag: AttachmentReference.RenderingFlag init( original: AttachmentDataSource?, segmented: [AttachmentDataSource]?, isViewOnce: Bool, renderingFlag: AttachmentReference.RenderingFlag ) throws { // We only create data sources for the original or segments if we need to. // Stories use segments if available; non stories always need the original. // Creating data sources is expensive, so only create what we need, // but always at least one. guard original != nil || segmented != nil else { throw OWSAssertionError("Must have some kind of attachment!") } self.original = original self.segmented = segmented self.isViewOnce = isViewOnce self.renderingFlag = renderingFlag } var segmentedOrOriginal: [AttachmentDataSource] { if let segmented { return segmented } return [original].compacted() } } private class func segmentAttachmentsIfNecessary( for conversations: [ConversationItem], approvedAttachments: [SignalAttachment], hasNonStoryDestination: Bool, hasStoryDestination: Bool ) async throws -> [SegmentAttachmentResult] { let maxSegmentDurations = conversations.compactMap(\.videoAttachmentDurationLimit) guard hasStoryDestination, !maxSegmentDurations.isEmpty, let requiredSegmentDuration = maxSegmentDurations.min() else { // No need to segment! var results = [SegmentAttachmentResult]() for attachment in approvedAttachments { let dataSource: AttachmentDataSource = try deps.attachmentValidator.validateContents( dataSource: attachment.dataSource, shouldConsume: true, mimeType: attachment.mimeType, renderingFlag: attachment.renderingFlag, sourceFilename: attachment.sourceFilename ) try results.append(.init( original: dataSource, segmented: nil, isViewOnce: attachment.isViewOnceAttachment, renderingFlag: attachment.renderingFlag )) } return results } let qualityLevel = deps.databaseStorage.read(block: deps.imageQualityLevel.resolvedQuality(tx:)) let segmentedResults = try await withThrowingTaskGroup( of: (Int, SegmentAttachmentResult).self ) { taskGroup in for (index, attachment) in approvedAttachments.enumerated() { taskGroup.addTask(operation: { let segmentingResult = try await attachment.preparedForOutput(qualityLevel: qualityLevel) .segmentedIfNecessary(on: ThreadUtil.enqueueSendQueue, segmentDuration: requiredSegmentDuration) .awaitable() let originalDataSource: AttachmentDataSource? if hasNonStoryDestination || segmentingResult.segmented == nil { // We need to prepare the original, either because there are no segments // or because we are sending to a non-story which doesn't segment. originalDataSource = try deps.attachmentValidator.validateContents( dataSource: segmentingResult.original.dataSource, shouldConsume: true, mimeType: segmentingResult.original.mimeType, renderingFlag: segmentingResult.original.renderingFlag, sourceFilename: segmentingResult.original.sourceFilename ) } else { originalDataSource = nil } let segmentedDataSources: [AttachmentDataSource]? = try { () -> [AttachmentDataSource]? in guard let segments = segmentingResult.segmented, hasStoryDestination else { return nil } var segmentedDataSources = [AttachmentDataSource]() for segment in segments { let dataSource: AttachmentDataSource = try deps.attachmentValidator.validateContents( dataSource: segment.dataSource, shouldConsume: true, mimeType: segment.mimeType, renderingFlag: segment.renderingFlag, sourceFilename: segment.sourceFilename ) segmentedDataSources.append(dataSource) } return segmentedDataSources }() return try (index, .init( original: originalDataSource, segmented: segmentedDataSources, isViewOnce: attachment.isViewOnceAttachment, renderingFlag: attachment.renderingFlag )) }) } var segmentedResults = [SegmentAttachmentResult?].init(repeating: nil, count: approvedAttachments.count) for try await result in taskGroup { segmentedResults[result.0] = result.1 } return segmentedResults.compacted() } return segmentedResults } // MARK: - Preparing messages private class func prepareForSending( destinations: [Destination], messageBodyForStories: MessageBody?, approvedAttachments: [SegmentAttachmentResult], tx: SDSAnyWriteTransaction ) throws -> ([TSThread], [PreparedOutgoingMessage]) { let segmentedAttachments = approvedAttachments.reduce([], { arr, segmented in return arr + segmented.segmentedOrOriginal.map { ($0, segmented.renderingFlag == .shouldLoop) } }) let unsegmentedAttachments = approvedAttachments.compactMap { (attachment) -> SignalAttachment.ForSending? in guard let original = attachment.original else { return nil } return SignalAttachment.ForSending( dataSource: original.tsDataSource, isViewOnce: attachment.isViewOnce, renderingFlag: attachment.renderingFlag ) } var nonStoryThreads = [Destination]() var privateStoryThreads = [TSPrivateStoryThread]() var groupStoryThreads = [TSGroupThread]() for destination in destinations { let conversation = destination.conversationItem let thread = destination.thread switch conversation.outgoingMessageType { case .message: owsAssertDebug(conversation.limitsVideoAttachmentLengthForStories == false) nonStoryThreads.append(destination) case .storyMessage where thread is TSPrivateStoryThread: owsAssertDebug(conversation.limitsVideoAttachmentLengthForStories == true) privateStoryThreads.append(thread as! TSPrivateStoryThread) case .storyMessage where thread is TSGroupThread: owsAssertDebug(conversation.limitsVideoAttachmentLengthForStories == true) groupStoryThreads.append(thread as! TSGroupThread) case .storyMessage: throw OWSAssertionError("Invalid story message target!") } } let nonStoryMessages = try prepareNonStoryMessages( threadDestinations: nonStoryThreads, unsegmentedAttachments: unsegmentedAttachments, isViewOnceMessage: approvedAttachments.contains(where: \.isViewOnce), tx: tx ) let storyMessageBuilders = try storyMessageBuilders( segmentedAttachments: segmentedAttachments, approvedMessageBody: messageBodyForStories, tx: tx ) let groupStoryMessages = try prepareGroupStoryMessages( groupStoryThreads: groupStoryThreads, builders: storyMessageBuilders, tx: tx ) let privateStoryMessages = try preparePrivateStoryMessages( privateStoryThreads: privateStoryThreads, builders: storyMessageBuilders, tx: tx ) let preparedMessages = nonStoryMessages + groupStoryMessages + privateStoryMessages let allThreads = nonStoryThreads.map(\.thread) + groupStoryThreads + privateStoryThreads return (allThreads, preparedMessages) } private class func prepareForSending( conversations: [ConversationItem], _ textAttachment: UnsentTextAttachment.ForSending, tx: SDSAnyWriteTransaction ) throws -> ([TSThread], [PreparedOutgoingMessage]) { let storyMessageBuilder = try storyMessageBuilder(textAttachment: textAttachment, tx: tx) var allStoryThreads = [TSThread]() var privateStoryThreads = [TSPrivateStoryThread]() var groupStoryThreads = [TSGroupThread]() for conversation in conversations { guard let thread = conversation.getOrCreateThread(transaction: tx) else { throw OWSAssertionError("Missing thread for conversation") } switch conversation.outgoingMessageType { case .message: throw OWSAssertionError("Cannot send TextAttachment to chats.") case .storyMessage where thread is TSPrivateStoryThread: privateStoryThreads.append(thread as! TSPrivateStoryThread) case .storyMessage where thread is TSGroupThread: groupStoryThreads.append(thread as! TSGroupThread) case .storyMessage: throw OWSAssertionError("Invalid story message target!") } allStoryThreads.append(thread) } let groupStoryMessages = try prepareGroupStoryMessages( groupStoryThreads: groupStoryThreads, builders: [storyMessageBuilder], tx: tx ) let privateStoryMessages = try preparePrivateStoryMessages( privateStoryThreads: privateStoryThreads, builders: [storyMessageBuilder], tx: tx ) let preparedMessages = groupStoryMessages + privateStoryMessages return (allStoryThreads, preparedMessages) } // MARK: Preparing Non-Story Messages private class func prepareNonStoryMessages( threadDestinations: [Destination], unsegmentedAttachments: [SignalAttachment.ForSending], isViewOnceMessage: Bool, tx: SDSAnyWriteTransaction ) throws -> [PreparedOutgoingMessage] { return try threadDestinations.map { destination in let thread = destination.thread // If this thread has a pending message request, treat it as accepted. ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest( thread, setDefaultTimerIfNecessary: true, tx: tx ) let preparedMessage = try prepareNonStoryMessage( messageBody: destination.messageBody, attachments: unsegmentedAttachments, thread: thread, tx: tx ) if let message = preparedMessage.messageForIntentDonation(tx: tx) { thread.donateSendMessageIntent(for: message, transaction: tx) } return preparedMessage } } private class func prepareNonStoryMessage( messageBody: ValidatedTSMessageBody?, attachments: [SignalAttachment.ForSending], thread: TSThread, tx: SDSAnyWriteTransaction ) throws -> PreparedOutgoingMessage { let unpreparedMessage = UnpreparedOutgoingMessage.build( thread: thread, messageBody: messageBody, mediaAttachments: attachments, quotedReplyDraft: nil, linkPreviewDataSource: nil, transaction: tx ) return try unpreparedMessage.prepare(tx: tx) } // MARK: Preparing Group Story Messages /// Prepare group stories for sending. /// /// Stories can only have one attachment, so we create a separate StoryMessage per attachment. /// /// Group stories otherwise behave similarly to message sends; we create one StoryMessage per group, /// and an OutgoingStoryMessage for each StoryMessage. private class func prepareGroupStoryMessages( groupStoryThreads: [TSGroupThread], builders: [StoryMessageBuilder], tx: SDSAnyWriteTransaction ) throws -> [PreparedOutgoingMessage] { return try groupStoryThreads .flatMap { groupThread in return try builders.map { builder in let storyMessage = try createAndInsertStoryMessage( builder: builder, groupThread: groupThread, tx: tx ) let outgoingMessage = OutgoingStoryMessage( thread: groupThread, storyMessage: storyMessage, storyMessageRowId: storyMessage.id!, skipSyncTranscript: false, transaction: tx ) return outgoingMessage } } .map { outgoingStoryMessage in return try UnpreparedOutgoingMessage.forOutgoingStoryMessage( outgoingStoryMessage, storyMessageRowId: outgoingStoryMessage.storyMessageRowId ).prepare(tx: tx) } } private class func createAndInsertStoryMessage( builder: StoryMessageBuilder, groupThread: TSGroupThread, tx: SDSAnyWriteTransaction ) throws -> StoryMessage { let storyManifest: StoryManifest = .outgoing( recipientStates: groupThread.recipientAddresses(with: tx) .lazy .compactMap { $0.serviceId } .dictionaryMappingToValues { _ in return .init(allowsReplies: true, contexts: []) } ) return try builder.build( groupId: groupThread.groupId, manifest: storyManifest, tx: tx ) } // MARK: Preparing Private Story Messages /// Prepare group stories for sending. /// /// Stories can only have one attachment, so we create a separate StoryMessage per attachment. /// /// For private story threads, we create a single StoryMessage per attachment across all of them. /// Then theres one OutgoingStoryMessage per thread, all pointing to that same StoryMessage. private class func preparePrivateStoryMessages( privateStoryThreads: [TSPrivateStoryThread], builders: [StoryMessageBuilder], tx: SDSAnyWriteTransaction ) throws -> [PreparedOutgoingMessage] { return try builders .flatMap { builder in let storyMessage = try createAndInsertStoryMessage( builder: builder, privateStoryThreads: privateStoryThreads, tx: tx ) return OutgoingStoryMessage.createDedupedOutgoingMessages( for: storyMessage, sendingTo: privateStoryThreads, tx: tx ) } .map { outgoingStoryMessage in return try UnpreparedOutgoingMessage.forOutgoingStoryMessage( outgoingStoryMessage, storyMessageRowId: outgoingStoryMessage.storyMessageRowId ).prepare(tx: tx) } } private class func createAndInsertStoryMessage( builder: StoryMessageBuilder, privateStoryThreads: [TSPrivateStoryThread], tx: SDSAnyWriteTransaction ) throws -> StoryMessage { var recipientStates = [ServiceId: StoryRecipientState]() for thread in privateStoryThreads { guard let threadUuid = UUID(uuidString: thread.uniqueId) else { throw OWSAssertionError("Invalid uniqueId for thread \(thread.uniqueId)") } for recipientAddress in thread.recipientAddresses(with: tx) { guard let recipient = recipientAddress.serviceId else { continue } let existingState = recipientStates[recipient] ?? .init(allowsReplies: false, contexts: []) let newState = StoryRecipientState( allowsReplies: existingState.allowsReplies || thread.allowsReplies, contexts: existingState.contexts + [threadUuid] ) recipientStates[recipient] = newState } } let storyManifest = StoryManifest.outgoing(recipientStates: recipientStates) return try builder.build( groupId: nil, manifest: storyManifest, tx: tx ) } // MARK: Generic story construction private struct StoryMessageBuilder { let attachmentBuilder: OwnedAttachmentBuilder let localAci: Aci let mediaCaption: StyleOnlyMessageBody? let shouldLoop: Bool func build( groupId: Data?, manifest: StoryManifest, tx: SDSAnyWriteTransaction ) throws -> StoryMessage { let storyMessage = try StoryMessage.createAndInsert( timestamp: Date.ows_millisecondTimestamp(), authorAci: self.localAci, groupId: groupId, manifest: manifest, replyCount: 0, attachmentBuilder: self.attachmentBuilder, mediaCaption: self.mediaCaption, shouldLoop: self.shouldLoop, transaction: tx ) return storyMessage } } private class func storyMessageBuilders( segmentedAttachments: [(AttachmentDataSource, isLoopingVideo: Bool)], approvedMessageBody: MessageBody?, tx: SDSAnyReadTransaction ) throws -> [StoryMessageBuilder] { guard let localAci = deps.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aci else { throw OWSAssertionError("Sending without a local aci!") } let storyCaption = approvedMessageBody? .hydrating(mentionHydrator: ContactsMentionHydrator.mentionHydrator(transaction: tx.asV2Read)) .asStyleOnlyBody() return segmentedAttachments.map { attachmentDataSource, isLoopingVideo in let attachmentManager = deps.attachmentManager let attachmentBuilder = OwnedAttachmentBuilder( info: .foreignReferenceAttachment, finalize: { owner, tx in try attachmentManager.createAttachmentStream( consuming: .init(dataSource: attachmentDataSource, owner: owner), tx: tx ) } ) return .init( attachmentBuilder: attachmentBuilder, localAci: localAci, mediaCaption: storyCaption, shouldLoop: isLoopingVideo ) } } private class func storyMessageBuilder( textAttachment: UnsentTextAttachment.ForSending, tx: SDSAnyWriteTransaction ) throws -> StoryMessageBuilder { guard let localAci = deps.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aci else { throw OWSAssertionError("Sending without a local aci!") } guard let textAttachmentBuilder = textAttachment .buildTextAttachment(transaction: tx)? .wrap({ StoryMessageAttachment.text($0) }) else { throw OWSAssertionError("Invalid text attachment") } return .init( attachmentBuilder: textAttachmentBuilder, localAci: localAci, mediaCaption: nil, shouldLoop: false ) } }