// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LibSignalClient import SignalCoreKit extension DeleteForMeSyncMessage { public enum Outgoing { public struct ThreadDeletionContext { fileprivate enum DeletionType { case conversation(ConversationDelete) case localOnlyConversation(LocalOnlyConversationDelete) } fileprivate let wrapped: DeletionType fileprivate init(_ conversation: ConversationDelete) { wrapped = .conversation(conversation) } fileprivate init(_ localOnly: LocalOnlyConversationDelete) { wrapped = .localOnlyConversation(localOnly) } } } } /// Responsible for sending `DeleteForMe` sync messages related to deletions /// originating on this device. public protocol DeleteForMeOutgoingSyncMessageManager { typealias Outgoing = DeleteForMeSyncMessage.Outgoing /// Send a sync message for the given deleted interactions. /// - Important /// All the given interactions must belong to the given thread. func send( deletedInteractions: [TSInteraction], thread: TSThread, tx: any DBWriteTransaction ) /// Send a sync message for the given thread deletion contexts. func send( threadDeletionContexts: [Outgoing.ThreadDeletionContext], tx: any DBWriteTransaction ) /// Build a deletion context for the given thread. /// /// This must be done before the thread is deleted, since it will look up /// the interactions the thread contains. func buildThreadDeletionContext( thread: TSThread, isFullDelete: Bool, tx: any DBReadTransaction ) -> Outgoing.ThreadDeletionContext? } final class DeleteForMeOutgoingSyncMessageManagerImpl: DeleteForMeOutgoingSyncMessageManager { private let addressableMessageFinder: any DeleteForMeAddressableMessageFinder private let deleteForMeSyncMessageSettingsStore: any DeleteForMeSyncMessageSettingsStore private let recipientDatabaseTable: any RecipientDatabaseTable private let syncMessageSender: any Shims.SyncMessageSender private let threadStore: any ThreadStore private let tsAccountManager: any TSAccountManager private let logger = PrefixedLogger(prefix: "[DeleteForMe]") init( addressableMessageFinder: any DeleteForMeAddressableMessageFinder, deleteForMeSyncMessageSettingsStore: any DeleteForMeSyncMessageSettingsStore, recipientDatabaseTable: any RecipientDatabaseTable, syncMessageSender: any Shims.SyncMessageSender, threadStore: any ThreadStore, tsAccountManager: any TSAccountManager ) { self.addressableMessageFinder = addressableMessageFinder self.deleteForMeSyncMessageSettingsStore = deleteForMeSyncMessageSettingsStore self.recipientDatabaseTable = recipientDatabaseTable self.syncMessageSender = syncMessageSender self.threadStore = threadStore self.tsAccountManager = tsAccountManager } func send( deletedInteractions: [TSInteraction], thread: TSThread, tx: any DBWriteTransaction ) { guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else { logger.error("Skipping individual-message delete sync: not registered!") return } guard let conversationIdentifier = conversationIdentifier(thread: thread, tx: tx) else { return } let addressableMessages = deletedInteractions.compactMap { interaction -> Outgoing.AddressableMessage? in if let incomingMessage = interaction as? TSIncomingMessage { return Outgoing.AddressableMessage(incomingMessage: incomingMessage) } else if let outgoingMessage = interaction as? TSOutgoingMessage { return Outgoing.AddressableMessage( outgoingMessage: outgoingMessage, localIdentifiers: localIdentifiers ) } return nil } if addressableMessages.isEmpty { return } for addressableMessageBatch in Batcher().batch(addressableMessages: addressableMessages) { /// The sync message supports sending individual-message deletes /// across multiple conversations in one message, but we don't have /// any UX affordances that'd let you do so in practice. let messageDeletes = Outgoing.MessageDeletes( conversationIdentifier: conversationIdentifier, addressableMessages: addressableMessageBatch ) sendSyncMessage( contents: DeleteForMeOutgoingSyncMessage.Contents( messageDeletes: [messageDeletes], conversationDeletes: [], localOnlyConversationDelete: [] ), tx: tx ) } } func send( threadDeletionContexts: [Outgoing.ThreadDeletionContext], tx: any DBWriteTransaction ) { for deletionContextBatch in Batcher().batch(threadDeletionContexts: threadDeletionContexts) { var conversationDeletes = [Outgoing.ConversationDelete]() var localOnlyConversationDeletes = [Outgoing.LocalOnlyConversationDelete]() for context in deletionContextBatch { switch context.wrapped { case .conversation(let conversation): conversationDeletes.append(conversation) case .localOnlyConversation(let localOnly): localOnlyConversationDeletes.append(localOnly) } } sendSyncMessage( contents: DeleteForMeOutgoingSyncMessage.Contents( messageDeletes: [], conversationDeletes: conversationDeletes, localOnlyConversationDelete: localOnlyConversationDeletes ), tx: tx ) } } func buildThreadDeletionContext( thread: TSThread, isFullDelete: Bool, tx: any DBReadTransaction ) -> Outgoing.ThreadDeletionContext? { guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else { logger.error("Skipping deletion context creation: not registered!") return nil } guard let conversationIdentifier = conversationIdentifier(thread: thread, tx: tx) else { return nil } let mostRecentAddressableMessages: [Outgoing.AddressableMessage] = addressableMessageFinder .findMostRecentAddressableMessages( threadUniqueId: thread.uniqueId, maxCount: 5, localIdentifiers: localIdentifiers, tx: tx ) if mostRecentAddressableMessages.isEmpty { return Outgoing.ThreadDeletionContext(Outgoing.LocalOnlyConversationDelete( conversationIdentifier: conversationIdentifier )) } else { return Outgoing.ThreadDeletionContext(Outgoing.ConversationDelete( conversationIdentifier: conversationIdentifier, mostRecentAddressableMessages: mostRecentAddressableMessages, isFullDelete: isFullDelete )) } } // MARK: - private func conversationIdentifier( thread: TSThread, tx: any DBReadTransaction ) -> Outgoing.ConversationIdentifier? { if let contactThread = thread as? TSContactThread, let contactServiceId = recipientDatabaseTable.fetchServiceId(contactThread: contactThread, tx: tx) { return .threadServiceId(serviceId: contactServiceId.serviceIdUppercaseString) } else if let contactThread = thread as? TSContactThread, let contactE164 = contactThread.contactPhoneNumber { return .threadE164(e164: contactE164) } else if let groupThread = thread as? TSGroupThread { return .threadGroupId(groupId: groupThread.groupId) } logger.warn("No conversation identifier for thread of type: \(type(of: thread)).") return nil } private func sendSyncMessage( contents: DeleteForMeOutgoingSyncMessage.Contents, tx: any DBWriteTransaction ) { guard deleteForMeSyncMessageSettingsStore.isSendingEnabled(tx: tx) else { logger.warn("Skipping delete-for-me sync message, feature not enabled!") return } guard let localThread = threadStore.getOrCreateLocalThread(tx: tx) else { logger.error("Missing local thread!") return } logger.info("Sending sync message: \(contents.messageDeletes.count) message deletes; \(contents.conversationDeletes.count) conversation deletes; \(contents.localOnlyConversationDelete.count) local-only conversation deletes.") syncMessageSender.sendSyncMessage( contents: contents, localThread: localThread, tx: tx ) } } // MARK: - Batching private extension DeleteForMeOutgoingSyncMessageManagerImpl { struct Batcher { /// The max number of deletes to include in a single sync message. Derived /// from envelope-math to estimate the max number that can fit into a single /// sync message, from an allowed-proto-size perspective, with wide margins. private enum MaxSyncMessageSizeConstants { static let maxInteractionsPerSyncMessage: Int = 500 static let maxThreadContextsPerSyncMessage: Int = 100 } func batch(addressableMessages: [Outgoing.AddressableMessage]) -> [[Outgoing.AddressableMessage]] { return batch( addressableMessages, maxBatchSize: MaxSyncMessageSizeConstants.maxInteractionsPerSyncMessage ) } func batch(threadDeletionContexts: [Outgoing.ThreadDeletionContext]) -> [[Outgoing.ThreadDeletionContext]] { return batch( threadDeletionContexts, maxBatchSize: MaxSyncMessageSizeConstants.maxThreadContextsPerSyncMessage ) } private func batch( _ items: any Sequence, maxBatchSize: Int ) -> [[T]] { var batches = [[T]]() var currentBatch = [T]() for item in items { if currentBatch.count < maxBatchSize { currentBatch.append(item) } else { batches.append(currentBatch) currentBatch = [item] } } batches.append(currentBatch) return batches } } } // MARK: - Shims extension DeleteForMeOutgoingSyncMessageManagerImpl { enum Shims { typealias SyncMessageSender = _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Shim } enum Wrappers { typealias SyncMessageSender = _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Wrapper } } protocol _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Shim { func sendSyncMessage( contents: DeleteForMeOutgoingSyncMessage.Contents, localThread: TSContactThread, tx: any DBWriteTransaction ) } final class _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Wrapper: _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Shim { private let messageSenderJobQueue: MessageSenderJobQueue init(_ messageSenderJobQueue: MessageSenderJobQueue) { self.messageSenderJobQueue = messageSenderJobQueue } func sendSyncMessage( contents: DeleteForMeOutgoingSyncMessage.Contents, localThread: TSContactThread, tx: any DBWriteTransaction ) { let sdsTx = SDSDB.shimOnlyBridge(tx) guard let syncMessage = DeleteForMeOutgoingSyncMessage( contents: contents, thread: localThread, tx: sdsTx ) else { return } messageSenderJobQueue.add( message: .preprepared(transientMessageWithoutAttachments: syncMessage), transaction: sdsTx ) } } // MARK: - Mock #if TESTABLE_BUILD open class MockDeleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager { var sendInteractionMock: (( _ interactions: [TSInteraction] ) -> Void)! public func send(deletedInteractions: [TSInteraction], thread: TSThread, tx: any DBWriteTransaction) { sendInteractionMock(deletedInteractions) } var sendDeletionContextMock: (( _ threadContexts: [Outgoing.ThreadDeletionContext] ) -> Void)! public func send(threadDeletionContexts: [Outgoing.ThreadDeletionContext], tx: any DBWriteTransaction) { sendDeletionContextMock(threadDeletionContexts) } public func buildThreadDeletionContext(thread: TSThread, isFullDelete: Bool, tx: any DBReadTransaction) -> Outgoing.ThreadDeletionContext? { let conversationIdentifier: Outgoing.ConversationIdentifier = if let contactThread = thread as? TSContactThread { .threadServiceId(serviceId: contactThread.contactUUID!) } else if let groupThread = thread as? TSGroupThread { .threadGroupId(groupId: groupThread.groupId) } else { owsFail("Invalid thread!") } return Outgoing.ThreadDeletionContext(Outgoing.LocalOnlyConversationDelete(conversationIdentifier: conversationIdentifier)) } } #endif