360 lines
13 KiB
Swift
360 lines
13 KiB
Swift
//
|
||
// 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
|