Signal-iOS/SignalServiceKit/Messages/Interactions/InteractionDeleteManager.swift
2024-06-11 16:39:22 -07:00

360 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalCoreKit
public enum InteractionDelete {
/// Specifies the desired side effects of deleting interactions.
public struct SideEffects {
/// Specifies what should happen with the ``CallRecord`` associated with
/// a ``TSInteraction`` being deleted, if one exists.
public enum AssociatedCallDeleteBehavior {
/// Delete any ``CallRecord`` associated with the interaction, and
/// send a `CallEvent` sync message about that deletion.
case localDeleteAndSendCallEventSyncMessage
/// Delete any ``CallRecord`` associated with the interaction.
case localDeleteOnly
}
/// Specifies behavior for updating the thread associated with an
/// interaction when that interaction is deleted.
public enum UpdateThreadOnInteractionDeleteBehavior {
/// Update the thread after the interaction is deleted.
case updateOnEachDeletedInteraction
/// Skip updating the thread. This value should be used to suppress
/// intermediate thread updates during a bulk interaction delete.
case doNotUpdate
}
/// Specifies behavior for sending a `DeleteForMe` sync message for any
/// deleted interactions.
public enum DeleteForMeSyncMessageBehavior {
/// Send a sync message.
/// - Important
/// Any interactions this case is applied to must match the given
/// thread.
case sendSyncMessage(interactionsThread: TSThread)
/// Do not send a sync message.
case doNotSend
}
let associatedCallDelete: AssociatedCallDeleteBehavior
let updateThreadOnInteractionDelete: UpdateThreadOnInteractionDeleteBehavior
let deleteForMeSyncMessage: DeleteForMeSyncMessageBehavior
private init(
associatedCallDelete: AssociatedCallDeleteBehavior,
updateThreadOnInteractionDelete: UpdateThreadOnInteractionDeleteBehavior,
deleteForMeSyncMessage: DeleteForMeSyncMessageBehavior
) {
self.associatedCallDelete = associatedCallDelete
self.updateThreadOnInteractionDelete = updateThreadOnInteractionDelete
self.deleteForMeSyncMessage = deleteForMeSyncMessage
}
public static func `default`() -> SideEffects {
return .custom()
}
public static func custom(
associatedCallDelete: AssociatedCallDeleteBehavior = .localDeleteAndSendCallEventSyncMessage,
updateThreadOnInteractionDelete: UpdateThreadOnInteractionDeleteBehavior = .updateOnEachDeletedInteraction,
deleteForMeSyncMessage: DeleteForMeSyncMessageBehavior = .doNotSend
) -> SideEffects {
return SideEffects(
associatedCallDelete: associatedCallDelete,
updateThreadOnInteractionDelete: updateThreadOnInteractionDelete,
deleteForMeSyncMessage: deleteForMeSyncMessage
)
}
}
}
/// Responsible for deleting ``TSInteraction``s, and initiating ``CallRecord``
/// deletion.
///
/// - Note
/// Every ``CallRecord`` is associated with a ``TSInteraction``, and when
/// one is deleted the other should be as well.
///
/// Correspondingly, this manager also provides an entrypoint for callers to
/// delete call records alongside their associated interactions. This may seem
/// counterintuitive, but avoids a circular dependency between interaction and
/// call record deletion.
///
/// - 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 InteractionDeleteManager {
typealias SideEffects = InteractionDelete.SideEffects
/// Remove the given interactions.
func delete(
interactions: [TSInteraction],
sideEffects: SideEffects,
tx: any DBWriteTransaction
)
/// Deletes the given call records and their associated interactions.
func delete(
alongsideAssociatedCallRecords callRecords: [CallRecord],
sideEffects: SideEffects,
tx: any DBWriteTransaction
)
}
public extension InteractionDeleteManager {
/// Remove the given interaction.
func delete(
_ interaction: TSInteraction,
sideEffects: SideEffects,
tx: any DBWriteTransaction
) {
delete(interactions: [interaction], sideEffects: sideEffects, tx: tx)
}
}
final class InteractionDeleteManagerImpl: InteractionDeleteManager {
private let callRecordStore: CallRecordStore
private let callRecordDeleteManager: CallRecordDeleteManager
private let databaseStorage: SDSDatabaseStorage
private let deleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager
private let interactionReadCache: InteractionReadCache
private let interactionStore: InteractionStore
private let mediaGalleryResourceManager: MediaGalleryResourceManager
private let messageSendLog: MessageSendLog
init(
callRecordStore: CallRecordStore,
callRecordDeleteManager: CallRecordDeleteManager,
databaseStorage: SDSDatabaseStorage,
deleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager,
interactionReadCache: InteractionReadCache,
interactionStore: InteractionStore,
mediaGalleryResourceManager: MediaGalleryResourceManager,
messageSendLog: MessageSendLog
) {
self.callRecordStore = callRecordStore
self.callRecordDeleteManager = callRecordDeleteManager
self.databaseStorage = databaseStorage
self.deleteForMeOutgoingSyncMessageManager = deleteForMeOutgoingSyncMessageManager
self.interactionReadCache = interactionReadCache
self.interactionStore = interactionStore
self.mediaGalleryResourceManager = mediaGalleryResourceManager
self.messageSendLog = messageSendLog
}
func delete(
interactions: [TSInteraction],
sideEffects: SideEffects,
tx: any DBWriteTransaction
) {
for interaction in interactions {
guard interaction.shouldBeSaved else {
return
}
_deleteInternal(
interaction: interaction,
knownAssociatedCallRecord: nil,
sideEffects: sideEffects,
tx: SDSDB.shimOnlyBridge(tx)
)
}
sendDeleteForMeSyncMessageIfNecessary(
interactions: interactions,
sideEffects: sideEffects,
tx: tx
)
}
func delete(
alongsideAssociatedCallRecords callRecords: [CallRecord],
sideEffects: SideEffects,
tx: any DBWriteTransaction
) {
var deletedInteractions = [TSInteraction]()
for callRecord in callRecords {
guard
let associatedInteraction: TSInteraction = interactionStore
.fetchAssociatedInteraction(callRecord: callRecord, tx: tx)
else { continue }
deletedInteractions.append(associatedInteraction)
CallRecord.assertDebugIsCallRecordInteraction(associatedInteraction)
_deleteInternal(
interaction: associatedInteraction,
knownAssociatedCallRecord: callRecord,
sideEffects: sideEffects,
tx: SDSDB.shimOnlyBridge(tx)
)
}
sendDeleteForMeSyncMessageIfNecessary(
interactions: deletedInteractions,
sideEffects: sideEffects,
tx: tx
)
}
private func sendDeleteForMeSyncMessageIfNecessary(
interactions: [TSInteraction],
sideEffects: SideEffects,
tx: any DBWriteTransaction
) {
switch sideEffects.deleteForMeSyncMessage {
case .sendSyncMessage(let interactionsThread):
owsAssert(
interactions.allSatisfy { $0.uniqueThreadId == interactionsThread.uniqueId },
"Thread did not match interaction!"
)
deleteForMeOutgoingSyncMessageManager.send(
deletedInteractions: interactions,
thread: interactionsThread,
tx: tx
)
case .doNotSend:
break
}
}
// MARK: -
private func _deleteInternal(
interaction: TSInteraction,
knownAssociatedCallRecord: CallRecord?,
sideEffects: SideEffects,
tx: SDSAnyWriteTransaction
) {
willRemove(
interaction: interaction,
knownAssociatedCallRecord: knownAssociatedCallRecord,
sideEffects: sideEffects,
tx: tx
)
tx.unwrapGrdbWrite.executeAndCacheStatement(
sql: "DELETE FROM model_TSInteraction WHERE uniqueId = ?",
arguments: [interaction.uniqueId]
)
didRemove(
interaction: interaction,
sideEffects: sideEffects,
tx: tx
)
}
private func willRemove(
interaction: TSInteraction,
knownAssociatedCallRecord: CallRecord?,
sideEffects: SideEffects,
tx: SDSAnyWriteTransaction
) {
databaseStorage.updateIdMapping(interaction: interaction, transaction: tx)
if
let callInteraction = interaction as? CallRecordAssociatedInteraction,
let interactionRowId = callInteraction.sqliteRowId,
let associatedCallRecord = knownAssociatedCallRecord ?? callRecordStore.fetch(
interactionRowId: interactionRowId, tx: tx.asV2Read
)
{
let sendSyncMessage = switch sideEffects.associatedCallDelete {
case .localDeleteOnly: false
case .localDeleteAndSendCallEventSyncMessage: true
}
callRecordDeleteManager.deleteCallRecord(
associatedCallRecord,
sendSyncMessageOnDelete: sendSyncMessage,
tx: tx.asV2Write
)
}
if let message = interaction as? TSMessage {
// Ensure any associated edits are removed before removing.
message.removeEdits(transaction: tx)
if
let sticker = message.messageSticker,
interactionStore.exists(uniqueId: message.uniqueId, tx: tx.asV2Write)
{
// StickerManager does ref-counting of known sticker packs. If this
// message is persisted i.e., if there will in fact be a deletion
// downstream of this call we should make sure that refcount gets
// updated.
//
// In a better world we wouldn't be unsure if the message was
// actually going to be deleted when we call this, but that's a
// problem for another day.
StickerManager.removeKnownStickerInfo(sticker.info, transaction: tx)
}
}
}
private func didRemove(
interaction: TSInteraction,
sideEffects: SideEffects,
tx: SDSAnyWriteTransaction
) {
switch sideEffects.updateThreadOnInteractionDelete {
case .updateOnEachDeletedInteraction:
if let associatedThread = interaction.thread(tx: tx) {
associatedThread.update(withRemovedMessage: interaction, transaction: tx)
}
case .doNotUpdate:
break
}
messageSendLog.deleteAllPayloadsForInteraction(interaction, tx: tx)
interactionReadCache.didRemove(interaction: interaction, transaction: tx)
if let message = interaction as? TSMessage {
FullTextSearchIndexer.delete(message, tx: tx)
if !message.attachmentIds.isEmpty {
mediaGalleryResourceManager.didRemove(message: message, tx: tx.asV2Write)
}
message.removeAllAttachments(tx: tx)
message.removeAllReactions(transaction: tx)
message.removeAllMentions(transaction: tx)
message.touchStoryMessageIfNecessary(replyCountIncrement: .replyDeleted, transaction: tx)
}
}
}
// MARK: - Mock
#if TESTABLE_BUILD
open class MockInteractionDeleteManager: InteractionDeleteManager {
var deleteInteractionsMock: ((
_ interactions: [TSInteraction],
_ sideEffects: SideEffects
) -> Void)?
open func delete(interactions: [TSInteraction], sideEffects: SideEffects, tx: any DBWriteTransaction) {
deleteInteractionsMock!(interactions, sideEffects)
}
var deleteAlongsideCallRecordsMock: ((
_ callRecords: [CallRecord],
_ sideEffects: SideEffects
) -> Void)?
open func delete(alongsideAssociatedCallRecords callRecords: [CallRecord], sideEffects: SideEffects, tx: any DBWriteTransaction) {
deleteAlongsideCallRecordsMock!(callRecords, sideEffects)
}
}
#endif