// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LibSignalClient /// A namespace for types related to "delete for me" sync messages. public enum DeleteForMeSyncMessage { public enum Incoming { public enum Conversation { case localUser(TSContactThread) case contact(TSContactThread) case group(TSGroupThread) var thread: TSThread { switch self { case .localUser(let contactThread): return contactThread case .contact(let contactThread): return contactThread case .group(let groupThread): return groupThread } } var threadUniqueId: String { return thread.uniqueId } } public struct AddressableMessage { enum Author: CustomStringConvertible { case localUser case otherUser(SignalRecipient) var description: String { switch self { case .localUser: return "localUser" case .otherUser(let recipient): return "\(recipient.address)" } } } let author: Author let sentTimestamp: UInt64 } } } /// Responsible for handling the actions contained in an incoming `DeleteForMe` /// sync message. /// /// - Note /// This is contrasted with "delete for everyone" actions, which use a /// ``TSOutgoingDeleteMessage`` to ask the recipients of a "target message" to /// delete that message and replace it with a tombstone. public protocol DeleteForMeIncomingSyncMessageManager { typealias Conversation = DeleteForMeSyncMessage.Incoming.Conversation typealias AddressableMessage = DeleteForMeSyncMessage.Incoming.AddressableMessage func handleMessageDelete( conversation: Conversation, addressableMessage: AddressableMessage, tx: DBWriteTransaction ) func handleConversationDelete( conversation: Conversation, mostRecentAddressableMessages: [AddressableMessage], isFullDelete: Bool, tx: DBWriteTransaction ) func handleLocalOnlyConversationDelete( conversation: Conversation, tx: DBWriteTransaction ) } final class DeleteForMeIncomingSyncMessageManagerImpl: DeleteForMeIncomingSyncMessageManager { private let addressableMessageFinder: any DeleteForMeAddressableMessageFinder private let bulkDeleteInteractionJobQueue: BulkDeleteInteractionJobQueue private let interactionDeleteManager: any InteractionDeleteManager private let threadSoftDeleteManager: any ThreadSoftDeleteManager private let logger = PrefixedLogger(prefix: "[DeleteForMe]") init( addressableMessageFinder: any DeleteForMeAddressableMessageFinder, bulkDeleteInteractionJobQueue: BulkDeleteInteractionJobQueue, interactionDeleteManager: any InteractionDeleteManager, threadSoftDeleteManager: any ThreadSoftDeleteManager, tsAccountManager: any TSAccountManager ) { self.addressableMessageFinder = addressableMessageFinder self.bulkDeleteInteractionJobQueue = bulkDeleteInteractionJobQueue self.interactionDeleteManager = interactionDeleteManager self.threadSoftDeleteManager = threadSoftDeleteManager } func handleMessageDelete( conversation: Conversation, addressableMessage: AddressableMessage, tx: any DBWriteTransaction ) { guard let message = addressableMessageFinder.findLocalMessage( threadUniqueId: conversation.threadUniqueId, addressableMessage: addressableMessage, tx: tx ) else { logger.warn("No message found for incoming message delete-sync: \(addressableMessage.author):\(addressableMessage.sentTimestamp).") return } interactionDeleteManager.delete( message, sideEffects: .custom(associatedCallDelete: .localDeleteOnly), tx: tx ) } func handleConversationDelete( conversation: Conversation, mostRecentAddressableMessages: [AddressableMessage], isFullDelete: Bool, tx: any DBWriteTransaction ) { let potentialAnchorMessages: [TSMessage] = mostRecentAddressableMessages .compactMap { addressableMessage in return addressableMessageFinder.findLocalMessage( threadUniqueId: conversation.threadUniqueId, addressableMessage: addressableMessage, tx: tx ) } if potentialAnchorMessages.isEmpty { logger.warn("No anchor messages found for incoming thread delete-sync: \(conversation.threadUniqueId).") return } /// We want to find a single "anchor" message before which we'll delete /// all other interactions. By describing multiple potential anchors in /// the sync message we improve the odds that this device will find its /// copy of one of those anchors. /// /// If we have multiple anchor candidates, we want the one that shows as /// "most recent" on this device; since we order by database insertion, /// we want the candidate that was most-recently inserted. /// /// This also helps mitigate issues in which this device's insertion /// order differs from the other device. For example, if the other /// device deleted messages ordered `{A,B,C}`, but this device inserted /// them as `{B,A,C}`, we still want to ensure all three messages are /// deleted. By sending all three messages as anchor candidates, this /// device can choose to use `B` as its anchor where the other device /// presumably used `A` to achieve the same end result. /// /// It's not perfect, but it should be a decent approximation given we /// can't make guarantees. let localAnchorMessage: TSMessage = potentialAnchorMessages.max { lhs, rhs in return lhs.sqliteRowId! < rhs.sqliteRowId! }! /// This is potentially a heavy and long-running operation, if we're /// deleting a large number of interactions (e.g., deleted a very old /// thread with a lot of messages). Consequently, we'll enqueue it as a /// durable job (which will do batched deletions internally). bulkDeleteInteractionJobQueue.addJob( anchorMessageRowId: localAnchorMessage.sqliteRowId!, isFullThreadDelete: isFullDelete, threadUniqueId: conversation.threadUniqueId, tx: SDSDB.shimOnlyBridge(tx) ) } func handleLocalOnlyConversationDelete( conversation: Conversation, tx: any DBWriteTransaction ) { if addressableMessageFinder.threadContainsAnyAddressableMessages( threadUniqueId: conversation.threadUniqueId, tx: tx ) { // This would be niche, but not impossibe given the right set of // conditions (e.g., devices offline at the wrong times, etc). We'll // err on the side of caution here, and not delete. logger.warn("Ignoring local-only conversation delete, conversation has addressable messages!") return } /// It's not likely there'll be many local-only messages, so we'll /// handle them synchronously. This also mitigates the concern of "what /// happens if a non-local message shows up in the thread while we're /// doing asynchronous delete", since we have no "anchor" message before /// which we know it's safe to delete. threadSoftDeleteManager.softDelete( threads: [conversation.thread], sendDeleteForMeSyncMessage: false, tx: tx ) } }