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

import GRDB
public import LibSignalClient

public enum RemoteDeleteAuthor: Equatable {
    case admin(aci: Aci, displayName: String)
    case regular(displayName: String)
    case localUser
}

public class AdminDeleteManager {
    public typealias RecipientAddressStates = [SignalServiceAddress: TSOutgoingMessageRecipientState]

    public struct DeleteType: OptionSet {
        public init(rawValue: Int) {
            self.rawValue = rawValue
        }

        public let rawValue: Int

        public static let admin = DeleteType(rawValue: 1 << 1)
        public static let regular = DeleteType(rawValue: 1 << 2)
    }

    private let recipientDatabaseTable: RecipientDatabaseTable
    private let tsAccountManager: TSAccountManager
    private let kvStore: NewKeyValueStore
    private let storageServiceManager: StorageServiceManager

    private static let kvStoreAdminDeleteEducationReadKey = "adminDeleteEducationRead"

    private let logger = PrefixedLogger(prefix: "AdminDelete")

    init(
        recipientDatabaseTable: RecipientDatabaseTable,
        tsAccountManager: TSAccountManager,
        storageServiceManager: StorageServiceManager,
    ) {
        self.recipientDatabaseTable = recipientDatabaseTable
        self.tsAccountManager = tsAccountManager
        self.kvStore = NewKeyValueStore(collection: "AdminDeleteManager")
        self.storageServiceManager = storageServiceManager
    }

    private func insertAdminDelete(
        groupThread: TSGroupThread,
        interactionId: Int64,
        deleteAuthor: Aci,
        tx: DBWriteTransaction,
    ) throws(TSMessage.RemoteDeleteError) {
        guard
            let deleteAuthorId = recipientDatabaseTable.fetchRecipient(
                serviceId: deleteAuthor,
                transaction: tx,
            )?.id
        else {
            logger.error("Failed to process admin delete for missing signal recipient")
            throw .invalidDelete
        }

        failIfThrows {
            var adminDeleteRecord = AdminDeleteRecord(
                interactionId: interactionId,
                deleteAuthorId: deleteAuthorId,
            )
            try adminDeleteRecord.insert(tx.database)
        }
    }

    public func tryToAdminDeleteMessage(
        originalMessageAuthorAci: Aci,
        deleteAuthorAci: Aci,
        sentAtTimestamp: UInt64,
        groupThread: TSGroupThread,
        threadUniqueId: String?,
        serverTimestamp: UInt64,
        transaction: DBWriteTransaction,
    ) throws(TSMessage.RemoteDeleteError) {
        guard SDS.fitsInInt64(sentAtTimestamp) else {
            owsFailDebug("Unable to delete a message with invalid sentAtTimestamp: \(sentAtTimestamp)")
            throw .invalidDelete
        }

        guard
            let groupModel = groupThread.groupModel as? TSGroupModelV2,
            groupModel.membership.isFullMemberAndAdministrator(deleteAuthorAci)
        else {
            logger.error("Failed to process admin delete for non-admin")
            throw .invalidDelete
        }

        if
            let threadUniqueId, let messageToDelete = InteractionFinder.findMessage(
                withTimestamp: sentAtTimestamp,
                threadId: threadUniqueId,
                author: SignalServiceAddress(originalMessageAuthorAci),
                transaction: transaction,
            )
        {
            let allowDeleteTimeframe = RemoteConfig.current.adminDeleteMaxAgeInSeconds + .day
            let latestMessage = try TSMessage.remotelyDeleteMessage(
                messageToDelete,
                deleteAuthorAci: deleteAuthorAci,
                allowedDeleteTimeframeSeconds: allowDeleteTimeframe,
                serverTimestamp: serverTimestamp,
                transaction: transaction,
            )

            return try insertAdminDelete(
                groupThread: groupThread,
                interactionId: latestMessage.sqliteRowId!,
                deleteAuthor: deleteAuthorAci,
                tx: transaction,
            )
        } else {
            throw .deletedMessageMissing
        }
    }

    public func adminDeleteAuthor(interactionId: Int64, tx: DBReadTransaction) -> Aci? {
        guard BuildFlags.AdminDelete.receive else {
            return nil
        }

        return failIfThrows {
            guard
                let adminDeleteRecord = try AdminDeleteRecord
                    .filter(AdminDeleteRecord.Columns.interactionId == interactionId)
                    .fetchOne(tx.database)
            else {
                return nil
            }

            let signalRecipient = recipientDatabaseTable.fetchRecipient(
                rowId: adminDeleteRecord.deleteAuthorId,
                tx: tx,
            )
            return signalRecipient?.aci
        }
    }

    public func canAdminDeleteMessage(
        message: TSMessage,
        thread: TSThread,
        tx: DBReadTransaction,
    ) -> Bool {
        guard BuildFlags.AdminDelete.send else {
            return false
        }

        guard let groupThread = thread as? TSGroupThread else {
            return false
        }

        guard let localAci = tsAccountManager.localIdentifiers(tx: tx)?.aci else {
            return false
        }

        guard groupThread.groupModel.groupMembership.isFullMemberAndAdministrator(localAci) else {
            return false
        }

        guard message.canBeRemotelyDeletedByAdmin else {
            return false
        }

        return true
    }

    public func insertAdminDeleteForSignalRecipient(
        _ recipientId: SignalRecipient.RowId,
        interactionId: Int64,
        tx: DBWriteTransaction,
    ) {
        failIfThrows {
            var adminDeleteRecord = AdminDeleteRecord(
                interactionId: interactionId,
                deleteAuthorId: recipientId,
            )
            try adminDeleteRecord.insert(tx.database)
        }
    }

    public func adminDeleteEducationReadStatus(tx: DBReadTransaction) -> Bool {
        return kvStore.fetchValue(Bool.self, forKey: Self.kvStoreAdminDeleteEducationReadKey, tx: tx) ?? false
    }

    public func setAdminDeleteEducationRead(tx: DBWriteTransaction, updateStorageService: Bool) {
        guard !adminDeleteEducationReadStatus(tx: tx) else {
            return
        }
        kvStore.writeValue(true, forKey: Self.kvStoreAdminDeleteEducationReadKey, tx: tx)
        if updateStorageService {
            storageServiceManager.recordPendingLocalAccountUpdates()
        }
    }

    // MARK: - Recipient states

    public static func updateRecipientStatesAdminDelete(recipientAddressStates: RecipientAddressStates?, interactionId: Int64, tx: DBWriteTransaction) {

        failIfThrows {
            var adminDeleteRecord = try AdminDeleteRecord
                .filter(AdminDeleteRecord.Columns.interactionId == interactionId)
                .fetchOne(tx.database)
            adminDeleteRecord?.recipientAddressStates = recipientAddressStates
            try adminDeleteRecord?.update(tx.database)
        }
    }

    public static func isFailedAdminDelete(recipientAddressStates: RecipientAddressStates?) -> Bool {
        guard let recipientAddressStates else {
            return false
        }
        return recipientAddressStates.values.contains {
            $0.status == .failed
        }
    }

    public static func wasSentToAnyRecipient(recipientAddressStates: RecipientAddressStates?) -> Bool {
        guard let recipientAddressStates else {
            return false
        }

        return recipientAddressStates.values.contains {
            switch $0.status {
            case .sent, .delivered, .read, .viewed:
                return true
            case .skipped, .sending, .pending, .failed:
                return false
            }
        }
    }

    public static func failedRecipientsWithErrorCode(_ errorCode: Int, recipientAddressStates: RecipientAddressStates?) -> [SignalServiceAddress] {
        guard let recipientAddressStates else {
            return []
        }

        return recipientAddressStates.filter { _, state in
            state.status == .failed && state.errorCode == errorCode
        }.map { $0.key }
    }

    public static func recipientAddressStates(message: TSMessage, tx: DBReadTransaction) -> RecipientAddressStates? {
        failIfThrows {
            try AdminDeleteRecord
                .filter(AdminDeleteRecord.Columns.interactionId == message.sqliteRowId!)
                .fetchOne(tx.database)?.recipientAddressStates
        }
    }
}