Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Threads/ThreadSoftDeleteManager.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Intents
import LibSignalClient

/// Responsible for "soft-deleting" threads, or removing their contents without
/// removing the `TSThread` record itself. The app's architecture is to never\*
/// delete the thread itself, but instead to delete all data associated with the
/// thread, in case the thread is needed again later on.
///
/// \*Threads can be hard-deleted, but only in niche scenarios.
///
/// - SeeAlso ``ThreadRemover``.
///
/// - SeeAlso
/// If you're calling this type for a user-initiated deletion, consider using
/// ``DeleteForMeInfoSheetCoordinator`` in the Signal target instead, which
/// handles some one-time informational UX.
public protocol ThreadSoftDeleteManager {
    func softDelete(
        threads: [TSThread],
        sendDeleteForMeSyncMessage: Bool,
        tx: DBWriteTransaction,
    )

    func removeAllInteractions(
        thread: TSThread,
        sendDeleteForMeSyncMessage: Bool,
        tx: DBWriteTransaction,
    )

    func removeIntentsForTerminatedGroup(threadUniqueId: String)
}

final class ThreadSoftDeleteManagerImpl: ThreadSoftDeleteManager {
    private enum Constants {
        static let interactionDeletionBatchSize: Int = 500
    }

    private typealias SyncMessageContext = DeleteForMeSyncMessage.Outgoing.ThreadDeletionContext

    private let deleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager
    private let intentsManager: Shims.IntentsManager
    private let interactionDeleteManager: InteractionDeleteManager
    private let recipientDatabaseTable: RecipientDatabaseTable
    private let storyManager: Shims.StoryManager
    private let threadReplyInfoStore: ThreadReplyInfoStore
    private let tsAccountManager: TSAccountManager

    private let logger = PrefixedLogger(prefix: "[ThreadDeleteMgr]")

    init(
        deleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager,
        intentsManager: Shims.IntentsManager,
        interactionDeleteManager: InteractionDeleteManager,
        recipientDatabaseTable: RecipientDatabaseTable,
        storyManager: Shims.StoryManager,
        threadReplyInfoStore: ThreadReplyInfoStore,
        tsAccountManager: TSAccountManager,
    ) {
        self.deleteForMeOutgoingSyncMessageManager = deleteForMeOutgoingSyncMessageManager
        self.intentsManager = intentsManager
        self.interactionDeleteManager = interactionDeleteManager
        self.recipientDatabaseTable = recipientDatabaseTable
        self.storyManager = storyManager
        self.threadReplyInfoStore = threadReplyInfoStore
        self.tsAccountManager = tsAccountManager
    }

    func softDelete(
        threads: [TSThread],
        sendDeleteForMeSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) {
        var syncMessageContexts = [SyncMessageContext]()

        for thread in threads {
            var syncMessageContext: SyncMessageContext?
            if
                sendDeleteForMeSyncMessage,
                let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx)
            {
                syncMessageContext = deleteForMeOutgoingSyncMessageManager.makeThreadDeletionContext(
                    thread: thread,
                    isFullDelete: true,
                    localIdentifiers: localIdentifiers,
                    tx: tx,
                )
            }

            softDelete(
                thread: thread,
                syncMessageContext: syncMessageContext,
                tx: tx,
            )

            if let syncMessageContext {
                syncMessageContexts.append(syncMessageContext)
            }
        }

        if sendDeleteForMeSyncMessage {
            deleteForMeOutgoingSyncMessageManager.send(
                threadDeletionContexts: syncMessageContexts,
                tx: tx,
            )
        }
    }

    func removeAllInteractions(
        thread: TSThread,
        sendDeleteForMeSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) {
        var syncMessageContext: SyncMessageContext?
        if
            sendDeleteForMeSyncMessage,
            let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx)
        {
            syncMessageContext = deleteForMeOutgoingSyncMessageManager.makeThreadDeletionContext(
                thread: thread,
                isFullDelete: false,
                localIdentifiers: localIdentifiers,
                tx: tx,
            )
        }

        removeAllInteractions(
            thread: thread,
            syncMessageContext: syncMessageContext,
            tx: tx,
        )

        if let syncMessageContext {
            deleteForMeOutgoingSyncMessageManager.send(
                threadDeletionContexts: [syncMessageContext],
                tx: tx,
            )
        }
    }

    func removeIntentsForTerminatedGroup(threadUniqueId: String) {
        intentsManager.deleteAllIntents(withGroupIdentifier: threadUniqueId)
    }

    private func softDelete(
        thread: TSThread,
        syncMessageContext: SyncMessageContext?,
        tx: DBWriteTransaction,
    ) {
        logger.info("Deleting thread with ID \(thread.logString).")

        removeAllInteractions(
            thread: thread,
            syncMessageContext: syncMessageContext,
            tx: tx,
        )

        thread.anyUpdate(transaction: tx) { thread in
            thread.messageDraft = nil
            thread.shouldThreadBeVisible = false
        }
        threadReplyInfoStore.remove(for: thread.uniqueId, tx: tx)

        if
            let contactThread = thread as? TSContactThread,
            let contactAci = recipientDatabaseTable.fetchServiceId(contactThread: contactThread, tx: tx)
                .flatMap({ $0 as? Aci }),
            let localIdentifiers = self.tsAccountManager.localIdentifiers(tx: tx),
            !localIdentifiers.contains(serviceId: contactAci)
        {
            storyManager.deleteAllStories(contactAci: contactAci, tx: tx)
        } else if let groupThread = thread as? TSGroupThread {
            storyManager.deleteAllStories(groupId: groupThread.groupId, tx: tx)
        }

        intentsManager.deleteAllIntents(withGroupIdentifier: thread.uniqueId)
    }

    private func removeAllInteractions(
        thread: TSThread,
        syncMessageContext: SyncMessageContext?,
        tx: DBWriteTransaction,
    ) {
        do {
            var moreInteractionsRemaining = true
            while moreInteractionsRemaining {
                try autoreleasepool {
                    let interactionBatch = try InteractionFinder(
                        threadUniqueId: thread.uniqueId,
                    ).fetchAllInteractions(
                        rowIdFilter: .newest,
                        limit: Constants.interactionDeletionBatchSize,
                        tx: tx,
                    )

                    if let syncMessageContext {
                        for messageToDelete: TSMessage in interactionBatch.compactMap({ $0 as? TSMessage }) {
                            syncMessageContext.registerMessageDeletedFromThread(messageToDelete)
                        }
                    }

                    interactionDeleteManager.delete(
                        interactions: interactionBatch,
                        sideEffects: .custom(
                            associatedCallDelete: .localDeleteOnly,
                            updateThreadOnInteractionDelete: .doNotUpdate,
                        ),
                        tx: tx,
                    )

                    moreInteractionsRemaining = !interactionBatch.isEmpty
                }
            }
        } catch {
            owsFailDebug("Failed to delete batch of interactions!")
            return
        }

        /// Because we skipped updating the thread for each deleted interaction,
        /// now that we're done deleting we'll do a one-time update of
        /// properties on the thread.
        thread.anyUpdate(transaction: tx) { thread in
            thread.lastInteractionRowId = 0
            thread.lastDraftInteractionRowId = 0
            thread.lastDraftUpdateTimestamp = 0
        }
    }
}

// MARK: - Shims

extension ThreadSoftDeleteManagerImpl {
    enum Shims {
        typealias StoryManager = _ThreadSoftDeleteManagerImpl_StoryManager_Shim
        typealias IntentsManager = _ThreadSoftDeleteManagerImpl_IntentsManager_Shim
    }

    enum Wrappers {
        typealias StoryManager = _ThreadSoftDeleteManagerImpl_StoryManager_Wrapper
        typealias IntentsManager = _ThreadSoftDeleteManagerImpl_IntentsManager_Wrapper
    }
}

// MARK: StoryManager

protocol _ThreadSoftDeleteManagerImpl_StoryManager_Shim {
    func deleteAllStories(contactAci: Aci, tx: DBWriteTransaction)
    func deleteAllStories(groupId: Data, tx: DBWriteTransaction)
}

final class _ThreadSoftDeleteManagerImpl_StoryManager_Wrapper: _ThreadSoftDeleteManagerImpl_StoryManager_Shim {
    init() {}

    func deleteAllStories(contactAci: Aci, tx: DBWriteTransaction) {
        StoryManager.deleteAllStories(forSender: contactAci, tx: tx)
    }

    func deleteAllStories(groupId: Data, tx: DBWriteTransaction) {
        StoryManager.deleteAllStories(forGroupId: groupId, tx: tx)
    }
}

// MARK: Intents

protocol _ThreadSoftDeleteManagerImpl_IntentsManager_Shim {
    func deleteAllIntents(withGroupIdentifier groupIdentifier: String)
}

final class _ThreadSoftDeleteManagerImpl_IntentsManager_Wrapper: _ThreadSoftDeleteManagerImpl_IntentsManager_Shim {
    init() {}

    func deleteAllIntents(withGroupIdentifier groupIdentifier: String) {
        INInteraction.delete(with: groupIdentifier)
    }
}

// MARK: -

#if TESTABLE_BUILD

open class MockThreadSoftDeleteManager: ThreadSoftDeleteManager {
    open func softDelete(threads: [TSThread], sendDeleteForMeSyncMessage: Bool, tx: DBWriteTransaction) {}
    open func removeAllInteractions(thread: TSThread, sendDeleteForMeSyncMessage: Bool, tx: DBWriteTransaction) {}
    open func removeIntentsForTerminatedGroup(threadUniqueId: String) {}
}

#endif