TM-SGNL-iOS/SignalUI/AttachmentMultisend/AttachmentMultisend.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

746 lines
30 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public 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<Void>]
do {
let destinations = try await Self.prepareForSending(
approvedMessageBody,
to: conversations,
db: deps.databaseStorage,
attachmentValidator: deps.attachmentValidator
)
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<Void>] = 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<Void>]
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<Void>] = 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
}
private static var deps = Dependencies(
attachmentManager: DependenciesBridge.shared.attachmentManager,
attachmentValidator: DependenciesBridge.shared.attachmentContentValidator,
contactsMentionHydrator: ContactsMentionHydrator.self,
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
imageQualityLevel: ImageQualityLevel.self,
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
tsAccountManager: DependenciesBridge.shared.tsAccountManager
)
// 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(segmentDuration: requiredSegmentDuration)
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,
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,
groupStoryThreads: groupStoryThreads,
privateStoryThreads: privateStoryThreads,
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]) {
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 storyMessageBuilder = try storyMessageBuilder(
textAttachment: textAttachment,
groupStoryThreads: groupStoryThreads,
privateStoryThreads: privateStoryThreads,
tx: tx
)
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: ValidatedMessageBody?,
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] {
if privateStoryThreads.isEmpty {
return []
}
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 {
/// Group id to builder; each group thread gets its own builder.
let groupThreadAttachmentBuilders: [Data: OwnedAttachmentBuilder<StoryMessageAttachment>]
/// All private story threads share a single builder (because they share a single StoryMessage).
let privateStoryThreadAttachmentBuilder: OwnedAttachmentBuilder<StoryMessageAttachment>?
let localAci: Aci
let mediaCaption: StyleOnlyMessageBody?
let shouldLoop: Bool
func build(
groupId: Data?,
manifest: StoryManifest,
tx: SDSAnyWriteTransaction
) throws -> StoryMessage {
let attachmentBuilder: OwnedAttachmentBuilder<StoryMessageAttachment>?
if let groupId {
attachmentBuilder = groupThreadAttachmentBuilders[groupId]
} else {
attachmentBuilder = privateStoryThreadAttachmentBuilder
}
guard let attachmentBuilder else {
throw OWSAssertionError("Building for an unprepared thread")
}
let storyMessage = try StoryMessage.createAndInsert(
timestamp: MessageTimestampGenerator.sharedInstance.generateTimestamp(),
authorAci: self.localAci,
groupId: groupId,
manifest: manifest,
replyCount: 0,
attachmentBuilder: attachmentBuilder,
mediaCaption: self.mediaCaption,
shouldLoop: self.shouldLoop,
transaction: tx
)
return storyMessage
}
}
private class func storyMessageBuilders(
segmentedAttachments: [(AttachmentDataSource, isLoopingVideo: Bool)],
approvedMessageBody: MessageBody?,
groupStoryThreads: [TSGroupThread],
privateStoryThreads: [TSPrivateStoryThread],
tx: SDSAnyReadTransaction
) throws -> [StoryMessageBuilder] {
if groupStoryThreads.isEmpty && privateStoryThreads.isEmpty {
// No story destinations, no need to build story messages.
return []
}
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<StoryMessageAttachment>(
info: .media,
finalize: { owner, tx in
try attachmentManager.createAttachmentStream(
consuming: .init(dataSource: attachmentDataSource, owner: owner),
tx: tx
)
}
)
return storyMessageBuilder(
attachmentBuilder: attachmentBuilder,
groupStoryThreads: groupStoryThreads,
privateStoryThreads: privateStoryThreads,
localAci: localAci,
mediaCaption: storyCaption,
shouldLoop: isLoopingVideo
)
}
}
private class func storyMessageBuilder(
textAttachment: UnsentTextAttachment.ForSending,
groupStoryThreads: [TSGroupThread],
privateStoryThreads: [TSPrivateStoryThread],
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 storyMessageBuilder(
attachmentBuilder: textAttachmentBuilder,
groupStoryThreads: groupStoryThreads,
privateStoryThreads: privateStoryThreads,
localAci: localAci,
mediaCaption: nil,
shouldLoop: false
)
}
private class func storyMessageBuilder(
attachmentBuilder: OwnedAttachmentBuilder<StoryMessageAttachment>,
groupStoryThreads: [TSGroupThread],
privateStoryThreads: [TSPrivateStoryThread],
localAci: Aci,
mediaCaption: StyleOnlyMessageBody?,
shouldLoop: Bool
) -> StoryMessageBuilder {
var numDestinations = groupStoryThreads.count
if !privateStoryThreads.isEmpty {
numDestinations += 1
}
let duplicatedAttachmentBuilders = attachmentBuilder.forMultisendReuse(
numDestinations: numDestinations
)
var groupThreadAttachmentBuilders = [Data: OwnedAttachmentBuilder<StoryMessageAttachment>]()
for (index, groupStoryThread) in groupStoryThreads.enumerated() {
groupThreadAttachmentBuilders[groupStoryThread.groupId] = duplicatedAttachmentBuilders[index]
}
return .init(
groupThreadAttachmentBuilders: groupThreadAttachmentBuilders,
privateStoryThreadAttachmentBuilder: privateStoryThreads.isEmpty
? nil
: duplicatedAttachmentBuilders.last,
localAci: localAci,
mediaCaption: mediaCaption,
shouldLoop: shouldLoop
)
}
}