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

import LibSignalClient

final class BackupArchiveSimpleChatUpdateArchiver {
    typealias Details = BackupArchive.InteractionArchiveDetails
    typealias ArchiveChatUpdateMessageResult = BackupArchive.ArchiveInteractionResult<Details>
    typealias RestoreChatUpdateMessageResult = BackupArchive.RestoreInteractionResult<Void>

    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<BackupArchive.InteractionUniqueId>
    private typealias RestoreFrameError = BackupArchive.RestoreFrameError<BackupArchive.ChatItemId>

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

    private let interactionStore: BackupArchiveInteractionStore

    init(interactionStore: BackupArchiveInteractionStore) {
        self.interactionStore = interactionStore
    }

    // MARK: -

    func archiveSimpleChatUpdate(
        infoMessage: TSInfoMessage,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveChatUpdateMessageResult {
        func messageFailure(
            _ error: ArchiveFrameError.ErrorType,
            line: UInt = #line,
        ) -> ArchiveChatUpdateMessageResult {
            return .messageFailure([.archiveFrameError(
                error,
                infoMessage.uniqueInteractionId,
                line: line,
            )])
        }

        /// To whom we should attribute this update.
        enum UpdateAuthor {
            /// A contact address computed while computing the update type. Useful
            /// if the update might appear in either a 1:1 or group thread.
            case precomputedAddress(BackupArchive.ContactAddress)
            /// The contact whose 1:1 thread this update appears in. This
            /// produces a failure if the update was in fact in a group.
            case containingContactThread
            /// The local user.
            case localUser
        }

        let updateAuthor: UpdateAuthor
        let updateType: BackupProto_SimpleChatUpdate.TypeEnum

        switch infoMessage.messageType {
        case
            .userNotRegistered,
            .typeUnsupportedMessage,
            .typeGroupQuit,
            .addToContactsOffer,
            .addUserToProfileWhitelistOffer,
            .addGroupToProfileWhitelistOffer,
            .syncedThread:
            // Skipped legacy types
            fallthrough
        case .recipientHidden:
            // Specifically-skipped and specially-handled type
            fallthrough
        case
            .typeGroupUpdate,
            .typeDisappearingMessagesUpdate,
            .profileUpdate,
            .threadMerge,
            .sessionSwitchover,
            .learnedProfileName,
            .typeEndPoll,
            .typePinnedMessage:
            // Non-simple chat update types
            return .completeFailure(.fatalArchiveError(
                .developerError(OWSAssertionError("Unexpected info message type: \(infoMessage.messageType)")),
            ))
        case .verificationStateChange:
            guard let verificationStateChangeMessage = infoMessage as? OWSVerificationStateChangeMessage else {
                return messageFailure(.verificationStateChangeNotExpectedSDSRecordType)
            }

            /// This type of info message historically put the user who had the
            /// verification-state change in the `recipientAddress` field.
            guard let recipientAddress = verificationStateChangeMessage.recipientAddress.asSingleServiceIdBackupAddress() else {
                return messageFailure(.verificationStateUpdateInteractionMissingAuthor)
            }

            updateAuthor = .precomputedAddress(recipientAddress)

            switch verificationStateChangeMessage.verificationState {
            case .default, .defaultAcknowledged, .noLongerVerified:
                updateType = .identityDefault
            case .verified:
                updateType = .identityVerified
            }
        case .phoneNumberChange:
            guard let changedNumberUserAci = infoMessage.phoneNumberChangeInfo()?.aci else {
                return messageFailure(.phoneNumberChangeInteractionMissingAuthor)
            }

            let recipientAddress = BackupArchive.ContactAddress(aci: changedNumberUserAci)
            updateAuthor = .precomputedAddress(recipientAddress)
            updateType = .changeNumber
        case .paymentsActivationRequest:
            switch infoMessage.paymentsActivationRequestAuthor(localIdentifiers: context.recipientContext.localIdentifiers) {
            case nil:
                return messageFailure(.paymentActivationRequestInteractionMissingAuthor)
            case .localUser:
                updateAuthor = .localUser
            case .otherUser(let aci):
                let authorAddress = BackupArchive.ContactAddress(aci: aci)
                updateAuthor = .precomputedAddress(authorAddress)
            }

            updateType = .paymentActivationRequest
        case .paymentsActivated:
            switch infoMessage.paymentsActivatedAuthor(localIdentifiers: context.recipientContext.localIdentifiers) {
            case nil:
                return messageFailure(.paymentsActivatedInteractionMissingAuthor)
            case .localUser:
                updateAuthor = .localUser
            case .otherUser(let aci):
                let authorAddress = BackupArchive.ContactAddress(aci: aci)
                updateAuthor = .precomputedAddress(authorAddress)
            }

            updateType = .paymentsActivated
        case .unknownProtocolVersion:
            guard let unknownProtocolVersionMessage = infoMessage as? OWSUnknownProtocolVersionMessage else {
                return messageFailure(.unknownProtocolVersionNotExpectedSDSRecordType)
            }

            /// This type of info message historically put the user who sent the
            /// unknown-protocol message in the `sender` field, or `nil` if it
            /// was ourselves.
            if let senderAddress = unknownProtocolVersionMessage.sender?.asSingleServiceIdBackupAddress() {
                updateAuthor = .precomputedAddress(senderAddress)
            } else {
                // This would be because we got a sync transcript of a message
                // with an unknown protocol version.
                updateAuthor = .localUser
            }

            updateType = .unsupportedProtocolMessage
        case .typeRemoteUserEndedSession:
            // Only inserted for 1:1 threads.
            updateAuthor = .containingContactThread
            updateType = .endSession
        case .typeLocalUserEndedSession:
            updateAuthor = .localUser
            updateType = .endSession
        case .userJoinedSignal:
            // Only inserted for 1:1 threads.
            updateAuthor = .containingContactThread
            updateType = .joinedSignal
        case .reportedSpam:
            // The reported-spam info message doesn't contain any info as to
            // what message we reported spam. Regardless, we were the one to
            // take this action, so we're the author.
            updateAuthor = .localUser
            updateType = .reportedSpam
        case .blockedOtherUser, .blockedGroup:
            // We blocked, so we're the author.
            //
            // These messages are the same in a Backup, and disambiguated at
            // restore-time by the type of chat thread they're in.
            updateAuthor = .localUser
            updateType = .blocked
        case .unblockedOtherUser, .unblockedGroup:
            // We unblocked, so we're the author.
            //
            // These messages are the same in a Backup, and disambiguated at
            // restore-time by the type of chat thread they're in.
            updateAuthor = .localUser
            updateType = .unblocked
        case .acceptedMessageRequest:
            // We accepted, so we're the author.
            updateAuthor = .localUser
            updateType = .messageRequestAccepted
        }

        let updateAuthorAddress: Details.AuthorAddress
        switch updateAuthor {
        case .precomputedAddress(let address):
            updateAuthorAddress = .contact(address)
        case .containingContactThread:
            switch threadInfo {
            case .groupThread:
                return messageFailure(.simpleChatUpdateMessageNotInContactThread)
            case .contactThread(let authorAddress):
                guard let authorAddress else {
                    return messageFailure(.simpleChatUpdateMessageNotInContactThread)
                }
                updateAuthorAddress = .contact(authorAddress)
            case .noteToSelfThread:
                updateAuthorAddress = .localUser
            }
        case .localUser:
            updateAuthorAddress = .localUser
        }

        var simpleChatUpdate = BackupProto_SimpleChatUpdate()
        simpleChatUpdate.type = updateType

        var chatUpdateMessage = BackupProto_ChatUpdateMessage()
        chatUpdateMessage.update = .simpleUpdate(simpleChatUpdate)

        return Details.validateAndBuild(
            interactionUniqueId: infoMessage.uniqueInteractionId,
            author: updateAuthorAddress,
            directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()),
            dateCreated: infoMessage.timestamp,
            expireStartDate: nil,
            expiresInMs: nil,
            isSealedSender: false,
            chatItemType: .updateMessage(chatUpdateMessage),
            isSmsPreviouslyRestoredFromBackup: false,
            threadInfo: threadInfo,
            pinMessageDetails: nil,
            context: context.recipientContext,
        )
    }

    func archiveSimpleChatUpdate(
        errorMessage: TSErrorMessage,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveChatUpdateMessageResult {
        func messageFailure(
            _ error: ArchiveFrameError.ErrorType,
            line: UInt = #line,
        ) -> ArchiveChatUpdateMessageResult {
            return .messageFailure([.archiveFrameError(
                error,
                errorMessage.uniqueInteractionId,
                line: line,
            )])
        }

        let updateAuthor: Details.AuthorAddress
        let updateType: BackupProto_SimpleChatUpdate.TypeEnum

        switch errorMessage.errorType {
        case .noSession:
            return .skippableInteraction(.legacyErrorMessage(.noSession))
        case .wrongTrustedIdentityKey:
            return .skippableInteraction(.legacyErrorMessage(.wrongTrustedIdentityKey))
        case .invalidKeyException:
            return .skippableInteraction(.legacyErrorMessage(.invalidKeyException))
        case .missingKeyId:
            return .skippableInteraction(.legacyErrorMessage(.missingKeyId))
        case .invalidMessage:
            return .skippableInteraction(.legacyErrorMessage(.invalidMessage))
        case .duplicateMessage:
            return .skippableInteraction(.legacyErrorMessage(.duplicateMessage))
        case .invalidVersion:
            return .skippableInteraction(.legacyErrorMessage(.invalidVersion))
        case .unknownContactBlockOffer:
            return .skippableInteraction(.legacyErrorMessage(.unknownContactBlockOffer))
        case .groupCreationFailed:
            return .skippableInteraction(.legacyErrorMessage(.groupCreationFailed))
        case .nonBlockingIdentityChange:
            /// This type of error message historically put the person with the
            /// identity-key change on the `recipientAddress` property.
            guard let recipientAddress = errorMessage.recipientAddress?.asSingleServiceIdBackupAddress() else {
                return messageFailure(.identityKeyChangeInteractionMissingAuthor)
            }

            updateType = .identityUpdate
            updateAuthor = .contact(recipientAddress)
        case .sessionRefresh:
            /// We always generate these ourselves.
            updateType = .chatSessionRefresh
            updateAuthor = .localUser
        case .decryptionFailure:
            /// This type of error message historically put the person who sent
            /// the failed-to-decrypt message in the `sender` property.
            guard let recipientAddress = errorMessage.sender?.asSingleServiceIdBackupAddress() else {
                return messageFailure(.decryptionErrorInteractionMissingAuthor)
            }

            updateType = .badDecrypt
            updateAuthor = .contact(recipientAddress)
        }

        var simpleChatUpdate = BackupProto_SimpleChatUpdate()
        simpleChatUpdate.type = updateType

        var chatUpdateMessage = BackupProto_ChatUpdateMessage()
        chatUpdateMessage.update = .simpleUpdate(simpleChatUpdate)

        return Details.validateAndBuild(
            interactionUniqueId: errorMessage.uniqueInteractionId,
            author: updateAuthor,
            directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()),
            dateCreated: errorMessage.timestamp,
            expireStartDate: nil,
            expiresInMs: nil,
            isSealedSender: false,
            chatItemType: .updateMessage(chatUpdateMessage),
            isSmsPreviouslyRestoredFromBackup: false,
            threadInfo: threadInfo,
            pinMessageDetails: nil,
            context: context.recipientContext,
        )
    }

    // MARK: -

    func restoreSimpleChatUpdate(
        _ simpleChatUpdate: BackupProto_SimpleChatUpdate,
        chatItem: BackupProto_ChatItem,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreChatUpdateMessageResult {
        func invalidProtoData(
            _ error: RestoreFrameError.ErrorType.InvalidProtoDataError,
            line: UInt = #line,
        ) -> RestoreChatUpdateMessageResult {
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(error),
                chatItem.id,
                line: line,
            )])
        }

        enum SimpleChatUpdateInteraction {
            case simpleInfoMessage(TSInfoMessageType)
            case prebuiltInfoMessage(TSInfoMessage)
            case errorMessage(TSErrorMessage)
        }

        let thread: TSThread = chatThread.tsThread
        let simpleChatUpdateInteraction: SimpleChatUpdateInteraction

        switch simpleChatUpdate.type {
        case .unknown, .UNRECOGNIZED:
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_SimpleChatUpdate.TypeEnum.self,
            ))
        case .joinedSignal:
            simpleChatUpdateInteraction = .simpleInfoMessage(.userJoinedSignal)
        case .identityUpdate:
            guard let verificationRecipient = context.recipientContext[chatItem.authorRecipientId] else {
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            }
            let contactAddress: BackupArchive.InteropAddress
            switch verificationRecipient {
            case .contact(let _contactAddress):
                contactAddress = _contactAddress.asInteropAddress()
            case .localAddress:
                contactAddress = context.recipientContext.localIdentifiers.aciAddress
            case .releaseNotesChannel, .callLink, .distributionList, .group:
                return invalidProtoData(.verificationStateChangeNotFromContact)
            }

            simpleChatUpdateInteraction = .errorMessage(.nonblockingIdentityChange(
                thread: thread,
                timestamp: chatItem.dateSent,
                // We'll use the author of this chat item as the user whose
                // identity key changed.
                address: contactAddress,
                // We'll fudge and conservatively say that the identity was not
                // previously verified since we don't have it tracked in the
                // backup and it only affects the action shown for the message.
                wasIdentityVerified: false,
            ))
        case .identityVerified, .identityDefault:
            guard let verificationRecipient = context.recipientContext[chatItem.authorRecipientId] else {
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            }
            let contactAddress: BackupArchive.InteropAddress
            switch verificationRecipient {
            case .contact(let _contactAddress):
                contactAddress = _contactAddress.asInteropAddress()
            case .localAddress:
                contactAddress = context.recipientContext.localIdentifiers.aciAddress
            case .releaseNotesChannel, .callLink, .distributionList, .group:
                return invalidProtoData(.verificationStateChangeNotFromContact)
            }

            let verificationState: OWSVerificationState = switch simpleChatUpdate.type {
            case .identityVerified: .verified
            case .identityDefault: .default
            default: owsFail("Impossible: checked above.")
            }

            simpleChatUpdateInteraction = .prebuiltInfoMessage(OWSVerificationStateChangeMessage(
                thread: thread,
                timestamp: chatItem.dateSent,
                // We'll use the author of this chat item as the user whose
                // verification state changed.
                recipientAddress: contactAddress,
                verificationState: verificationState,
                // We don't know which device this update originated on, so
                // we'll pretend it was the local. This only affects the way the
                // message is displayed.
                isLocalChange: true,
            ))
        case .changeNumber:
            guard let verificationRecipient = context.recipientContext[chatItem.authorRecipientId] else {
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            }
            guard
                case .contact(let contactAddress) = verificationRecipient,
                let aci = contactAddress.aci
            else {
                return invalidProtoData(.phoneNumberChangeNotFromContact)
            }

            simpleChatUpdateInteraction = .prebuiltInfoMessage(.makeForPhoneNumberChange(
                thread: thread,
                timestamp: chatItem.dateSent,
                aci: aci,
                oldNumber: nil,
                newNumber: nil,
            ))
        case .releaseChannelDonationRequest:
            // TODO: [Backups] Add support (and a test case!) for this once we've implemented the Release Notes channel.
            return .success(())
        case .endSession:
            guard let senderRecipient = context.recipientContext[chatItem.authorRecipientId] else {
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            }
            let infoMessageType: TSInfoMessageType
            switch senderRecipient {
            case .localAddress:
                infoMessageType = .typeLocalUserEndedSession
            case .contact:
                infoMessageType = .typeRemoteUserEndedSession
            case .releaseNotesChannel, .group, .distributionList, .callLink:
                return invalidProtoData(.endSessionNotFromContact)
            }

            simpleChatUpdateInteraction = .simpleInfoMessage(infoMessageType)
        case .chatSessionRefresh:
            simpleChatUpdateInteraction = .errorMessage(.sessionRefresh(
                thread: thread,
                timestamp: chatItem.dateSent,
            ))
        case .badDecrypt:
            guard let senderRecipient = context.recipientContext[chatItem.authorRecipientId] else {
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            }
            let contactAddress: BackupArchive.InteropAddress
            switch senderRecipient {
            case .localAddress:
                contactAddress = context.recipientContext.localIdentifiers.aciAddress
            case .contact(let _contactAddress):
                contactAddress = _contactAddress.asInteropAddress()
            case .releaseNotesChannel, .group, .distributionList, .callLink:
                return invalidProtoData(.decryptionErrorNotFromContact)
            }

            simpleChatUpdateInteraction = .errorMessage(.failedDecryption(
                thread: thread,
                timestamp: chatItem.dateSent,
                sender: contactAddress,
            ))
        case .paymentsActivated:
            let senderAci: Aci
            switch context.recipientContext[chatItem.authorRecipientId] {
            case nil:
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            case .localAddress:
                senderAci = context.recipientContext.localIdentifiers.aci
            case .contact(let contactAddress):
                guard let aci = contactAddress.aci else { fallthrough }
                senderAci = aci
            case .releaseNotesChannel, .group, .distributionList, .callLink:
                return invalidProtoData(.paymentsActivatedNotFromAci)
            }

            simpleChatUpdateInteraction = .prebuiltInfoMessage(.paymentsActivatedMessage(
                thread: thread,
                timestamp: chatItem.dateSent,
                senderAci: senderAci,
            ))
        case .paymentActivationRequest:
            let senderAci: Aci
            switch context.recipientContext[chatItem.authorRecipientId] {
            case nil:
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            case .localAddress:
                senderAci = context.recipientContext.localIdentifiers.aci
            case .contact(let contactAddress):
                guard let aci = contactAddress.aci else { fallthrough }
                senderAci = aci
            case .releaseNotesChannel, .group, .distributionList, .callLink:
                return invalidProtoData(.paymentsActivatedNotFromAci)
            }

            simpleChatUpdateInteraction = .prebuiltInfoMessage(.paymentsActivationRequestMessage(
                thread: thread,
                timestamp: chatItem.dateSent,
                senderAci: senderAci,
            ))
        case .unsupportedProtocolMessage:
            let senderAddress: SignalServiceAddress?
            switch context.recipientContext[chatItem.authorRecipientId] {
            case nil:
                return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
            case .localAddress:
                senderAddress = nil
            case .contact(let contactAddress):
                senderAddress = contactAddress.asInteropAddress()
            case .releaseNotesChannel, .group, .distributionList, .callLink:
                return invalidProtoData(.unsupportedProtocolVersionNotFromContact)
            }

            simpleChatUpdateInteraction = .prebuiltInfoMessage(OWSUnknownProtocolVersionMessage(
                thread: thread,
                timestamp: chatItem.dateSent,
                sender: senderAddress,
                // This isn't quite right, but we don't have the required
                // protocol version for this message in the backup. Setting it
                // to the highest we can insert into the DB ensures this message
                // will always show "unknown protocol version", but that's fine.
                protocolVersion: UInt(Int64.max),
            ))
        case .reportedSpam:
            simpleChatUpdateInteraction = .simpleInfoMessage(.reportedSpam)
        case .blocked:
            switch chatThread.threadType {
            case .contact:
                simpleChatUpdateInteraction = .simpleInfoMessage(.blockedOtherUser)
            case .groupV2:
                simpleChatUpdateInteraction = .simpleInfoMessage(.blockedGroup)
            }
        case .unblocked:
            switch chatThread.threadType {
            case .contact:
                simpleChatUpdateInteraction = .simpleInfoMessage(.unblockedOtherUser)
            case .groupV2:
                simpleChatUpdateInteraction = .simpleInfoMessage(.unblockedGroup)
            }
        case .messageRequestAccepted:
            simpleChatUpdateInteraction = .simpleInfoMessage(.acceptedMessageRequest)
        }

        switch simpleChatUpdateInteraction {
        case .simpleInfoMessage(let infoMessageType):
            let infoMessage = TSInfoMessage(
                thread: thread,
                messageType: infoMessageType,
                timestamp: chatItem.dateSent,
            )
            do {
                try interactionStore.insert(
                    infoMessage,
                    in: chatThread,
                    chatId: chatItem.typedChatId,
                    context: context,
                )
            } catch let error {
                return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
            }
        case .prebuiltInfoMessage(let infoMessage):
            do {
                try interactionStore.insert(
                    infoMessage,
                    in: chatThread,
                    chatId: chatItem.typedChatId,
                    context: context,
                )
            } catch let error {
                return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
            }
        case .errorMessage(let errorMessage):
            do {
                try interactionStore.insert(
                    errorMessage,
                    in: chatThread,
                    chatId: chatItem.typedChatId,
                    context: context,
                )
            } catch let error {
                return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
            }
        }

        return .success(())
    }
}