Signal-iOS/SignalServiceKit/Messages/DeviceSyncing/DeleteForMe/DeleteForMeOutgoingSyncMessageManager.swift

375 lines
14 KiB
Swift

//
// 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<T>(
_ items: any Sequence<T>,
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