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

import SwiftProtobuf

extension BackupArchive {

    public typealias RawError = Swift.Error

    // MARK: - Archiving

    /// Error while archiving a single frame.
    public struct ArchiveFrameError<AppIdType: BackupArchive.LoggableId>: BackupArchive.LoggableError {
        public enum ErrorType {
            /// Message types for which edit history is unexpected.
            public enum UnexpectedRevisionsMessageType {
                case remoteDeletedMessage
                case contactMessage
                case stickerMessage
                case updateMessage
                case paymentNotification
                case giftBadge
                case viewOnceMessage
                case poll
                case adminDeletedMessage
            }

            /// An error occurred serializing the proto.
            /// - Note
            /// Logging the raw error is safe, as it'll just contain proto field
            /// names.
            case protoSerializationError(RawError)
            /// An error occurred during file IO.
            /// - Note
            /// Logging the raw error is safe, as we generate the file we stream
            /// without user input.
            case fileIOError(RawError)

            /// The object we are archiving references a recipient that should already have an id assigned
            /// from having been archived, but does not.
            /// e.g. we try to archive a message to a recipient aci, but that aci has no ``BackupArchive.RecipientId``.
            case referencedRecipientIdMissing(RecipientArchivingContext.Address)

            /// The object we are archiving references a chat that should already have an id assigned
            /// from having been archived, but does not.
            /// e.g. we try to archive a message to a thread, but that group has no ``BackupArchive.ChatId``.
            case referencedThreadIdMissing(ThreadUniqueId)

            /// The object we are archiving references a custom chat color that should already have an id assigned
            /// from having been archived, but does not.
            /// e.g. we try to archive the chat style of a thread, but there is no ``BackupArchive.CustomChatColorId``.
            case referencedCustomChatColorMissing(CustomChatColor.Key)

            /// We were unable to fetch the OWSRecipientIdentity for a recipient.
            case unableToFetchRecipientIdentity(RawError)

            /// An error generating the master key for a group, causing the group to be skipped.
            case groupMasterKeyError(RawError)

            /// A contact thread has an invalid or missing address information, causing the
            /// thread to be skipped.
            case contactThreadMissingAddress
            /// There was a message in a contact thread with a recipient that was not self
            /// or the contact in the thread. We can recover from this and know
            /// historical bugs made it possible, but we log it nonetheless.
            case messageFromOtherRecipientInContactThread

            /// Custom chat colors should never have light/dark theme. The UI
            /// disallows it and the proto cannot represent it.
            case themedCustomChatColor

            /// A `TSInteraction` database row was invalid, and we couldn't
            /// instantiate a `TSInteraction` from it.
            case invalidInteractionDatabaseRow(RawError)
            /// An incoming message has an invalid or missing author address information,
            /// causing the message to be skipped.
            case invalidIncomingMessageAuthor
            /// An incoming message came from the self recipient.
            case incomingMessageFromSelf
            /// An message not from self in Note to Self
            case nonSelfAuthorInNoteToSelf
            /// An outgoing message has an invalid or missing recipient address information,
            /// causing the message to be skipped.
            case invalidOutgoingMessageRecipient
            /// An quote has an invalid or missing author address information,
            /// causing the containing message to be skipped.
            case invalidQuoteAuthor

            /// A quote of type `NORMAL` was missing both text and attachments.
            case quoteTypeNormalMissingTextAndAttachments

            /// A link preview is missing its url
            case linkPreviewMissingUrl
            /// A link preview's URL isn't in the message body
            case linkPreviewUrlNotInBody

            /// A sticker message had no associated attachment for the sticker's image contents.
            case stickerMessageMissingStickerAttachment

            /// A story reply did not have an aci author.
            case storyReplyAuthorMissingAci
            /// A story reply had empty context (text or reaction).
            case storyReplyEmptyContents
            /// We only support backing up 1:1 story replies.
            case storyReplyInGroupThread

            /// A reaction has an invalid or missing author address information, causing the
            /// reaction to be skipped.
            case invalidReactionAddress
            /// A reaction has an invalid (too large) timestamp.
            case invalidReactionTimestamp

            /// A group update message with no updates actually inside it, which is invalid.
            case emptyGroupUpdate

            /// The profile for the local user is missing.
            case missingLocalProfile
            /// The profile key for the local user is missing.
            case missingLocalProfileKey

            /// Parameters required to archive a GV2 group member are missing
            case missingRequiredGroupMemberParams

            /// A group call record had an invalid call status.
            case groupCallRecordHadInvalidCallStatus

            /// A distribution list had no distributionId; the distribution id assigned in the error should be ignored.
            case distributionListMissingDistributionId
            /// The recipients for a distribution list couldn't be fetched.
            case unableToFetchDistributionListRecipients
            /// A distribution list had ``TSThreadStoryViewMode/default``.
            case distributionListHasDefaultViewMode
            /// A custom (non-MyStory) distribution list had a ``TSThreadStoryViewMode/blocklist``.
            case customDistributionListBlocklistViewMode
            /// The story distribution list was marked as deleted but missing a deletion timestamp
            case distributionListMissingDeletionTimestamp
            /// The story distribution list was marked as deleted but had an invalid deletion timestamp.
            case distributionListInvalidTimestamp

            /// An interaction used to create a verification-state update was
            /// missing info as to its author.
            case verificationStateUpdateInteractionMissingAuthor
            /// An interaction used to create a phone number change was missing
            /// info as to its author.
            case phoneNumberChangeInteractionMissingAuthor
            /// An interaction used to create a payment activation request was
            /// missing info as to its author.
            case paymentActivationRequestInteractionMissingAuthor
            /// An interaction used to create a payments-activated request was
            /// missing info as to its author.
            case paymentsActivatedInteractionMissingAuthor
            /// An interaction used to create an identity-key change was missing
            /// info as to its author.
            case identityKeyChangeInteractionMissingAuthor
            /// An interaction used to create a decryption error update was
            /// missing info as to its author.
            case decryptionErrorInteractionMissingAuthor
            /// We found a non-simple chat update type when expecting a simple
            /// chat update.
            case foundComplexChatUpdateTypeWhenExpectingSimple
            /// A "verification state change" info message was not of the
            /// expected SDS record type, ``OWSVerificationStateChangeMessage``.
            case verificationStateChangeNotExpectedSDSRecordType
            /// An "unknown protocol version" info message was not of the
            /// expected SDS record type, ``OWSUnknownProtocolVersionMessage``.
            case unknownProtocolVersionNotExpectedSDSRecordType
            /// A simple chat update message that was expected to be in a 1:1
            /// thread was not, in fact, in a 1:1 thread.
            case simpleChatUpdateMessageNotInContactThread

            /// We failed to fetch payment information for a payment message.
            case paymentInfoFetchFailed(RawError)
            /// The payment message was missing required additional payment information.
            case missingPaymentInformation

            /// A "disappearing message config update" info message was not of
            /// the expected SDS record type, ``OWSDisappearingConfigurationUpdateInfoMessage``.
            case disappearingMessageConfigUpdateNotExpectedSDSRecordType
            /// An ``OWSDisappearingConfigurationUpdateInfoMessage`` info
            /// message was unexpectedly missing author info.
            case disappearingMessageConfigUpdateMissingAuthor

            /// A "profile change update" info message was missing author info.
            case profileChangeUpdateMissingAuthor
            /// A "profile change update" info message was missing the before or
            /// after profile name.
            case profileChangeUpdateMissingNames

            /// A "thread merge update" info message was missing info as to the
            /// contact whose threads were merged.
            case threadMergeUpdateMissingAuthor

            /// A "session switchover update" info message was missing info as
            /// to the switched-over-from session.
            case sessionSwitchoverUpdateMissingAuthor

            /// A "learned profile update" info message was missing the display
            /// name from before we learned the profile.
            case learnedProfileUpdateMissingPreviousName
            /// A "learned profile update" info message contained an invalid
            /// E164 as its "previous name".
            case learnedProfileUpdateInvalidE164
            /// A "learned profile update" info message was missing info as to
            /// the profile that was learned.
            case learnedProfileUpdateMissingAuthor

            /// We failed to fetch the edit history for a message.
            case editHistoryFailedToFetch

            /// We failed to read the ``StoryContextAssociatedData``. Note that it can
            /// be nil (missing); this is a SQL error when we tried to read.
            case unableToReadStoryContextAssociatedData(Error)

            /// An unviewed view-once message had an unexpected attachment count.
            case unviewedViewOnceMessageUnexpectedAttachmentCount(Int)

            /// An ad hoc call's ``CallRecord/conversationId`` is not a
            /// call link, which is illegal.
            case adHocCallDoesNotHaveCallLinkAsConversationId
            /// An ad hoc call has an invalid start timestamp.
            case invalidAdHocCallTimestamp

            /// A message of an unexpected type had edit history.
            case revisionsPresentOnUnexpectedMessage(UnexpectedRevisionsMessageType)
            /// A message's edit history contained an unexpected type.
            case revisionWasUnexpectedMessage(UnexpectedRevisionsMessageType)

            /// A poll terminate message was missing a question
            case pollEndMissingQuestion

            /// A poll terminate message was missing all persistable data
            case pollEndMissingPersistableData

            /// An interaction that claims to be a poll does not have associated poll data
            case pollMissing

            /// Poll option should have a rowId but it does not
            case pollOptionIdMissing

            /// Poll db row doesn't fit into PollRecord Swift type
            case invalidPollRecordDatabaseRow

            /// Poll option db row doesn't fit into PollOptionRecord Swift type
            case invalidPollOptionRecordDatabaseRow

            /// Poll vote db row doesn't fit into PollVoteRecord Swift type
            case invalidPollVoteRecordDatabaseRow

            /// An interaction that claims to be a poll does not have a poll question
            case pollMessageMissingQuestionBody

            /// A poll vote recipient id was not found
            case pollVoteAuthorSignalRecipientIdMissing

            /// Author Aci for end poll message was invalid
            case endPollUpdateInvalidAuthorAci

            /// A pin message chat update was missing all persistable data
            case pinMessageChatUpdateMissingPersistableData
        }

        private let type: ErrorType
        private let id: AppIdType
        private let file: StaticString
        private let function: StaticString
        private let line: UInt

        /// Create a new error instance.
        ///
        /// Exposed as a static method rather than an initializer to help
        /// callsites have some context without needing to put the exhaustive
        /// (namespaced) type name at each site.
        public static func archiveFrameError(
            _ type: ErrorType,
            _ id: AppIdType,
            file: StaticString = #file,
            function: StaticString = #function,
            line: UInt = #line,
        ) -> ArchiveFrameError {
            return ArchiveFrameError(type: type, id: id, file: file, function: function, line: line)
        }

        // MARK: BackupArchive.LoggableError

        public var typeLogString: String {
            return "ArchiveFrameError: \(String(describing: type))"
        }

        public var idLogString: String {
            switch type {
            case .distributionListMissingDistributionId:
                return "\(id.typeLogString).{ID missing}"
            default:
                return "\(id.typeLogString).\(id.idLogString)"
            }
        }

        public var callsiteLogString: String {
            return "\(file):\(function):\(line)"
        }

        public var collapseKey: String? {
            switch type {
            case .protoSerializationError(let rawError):
                // We don't want to re-log every instance of this we see.
                // Collapse them by the raw error itself.
                return "\(rawError)"
            case
                .referencedRecipientIdMissing,
                .referencedThreadIdMissing,
                .referencedCustomChatColorMissing,
                .contactThreadMissingAddress:
                // Collapse these by the id they refer to, which is in the "type".
                return idLogString
            case .incomingMessageFromSelf, .nonSelfAuthorInNoteToSelf, .messageFromOtherRecipientInContactThread:
                // Collapse these all together.
                return id.typeLogString
            case
                .fileIOError,
                .groupMasterKeyError,
                .themedCustomChatColor,
                .unableToFetchRecipientIdentity,
                .distributionListMissingDistributionId,
                .unableToFetchDistributionListRecipients,
                .distributionListHasDefaultViewMode,
                .customDistributionListBlocklistViewMode,
                .distributionListMissingDeletionTimestamp,
                .distributionListInvalidTimestamp,
                .invalidInteractionDatabaseRow,
                .invalidIncomingMessageAuthor,
                .invalidOutgoingMessageRecipient,
                .invalidQuoteAuthor,
                .quoteTypeNormalMissingTextAndAttachments,
                .linkPreviewMissingUrl,
                .linkPreviewUrlNotInBody,
                .stickerMessageMissingStickerAttachment,
                .storyReplyAuthorMissingAci,
                .storyReplyEmptyContents,
                .storyReplyInGroupThread,
                .invalidReactionAddress,
                .invalidReactionTimestamp,
                .emptyGroupUpdate,
                .missingLocalProfile,
                .missingLocalProfileKey,
                .missingRequiredGroupMemberParams,
                .groupCallRecordHadInvalidCallStatus,
                .verificationStateUpdateInteractionMissingAuthor,
                .phoneNumberChangeInteractionMissingAuthor,
                .identityKeyChangeInteractionMissingAuthor,
                .decryptionErrorInteractionMissingAuthor,
                .paymentActivationRequestInteractionMissingAuthor,
                .paymentsActivatedInteractionMissingAuthor,
                .foundComplexChatUpdateTypeWhenExpectingSimple,
                .verificationStateChangeNotExpectedSDSRecordType,
                .unknownProtocolVersionNotExpectedSDSRecordType,
                .simpleChatUpdateMessageNotInContactThread,
                .paymentInfoFetchFailed,
                .missingPaymentInformation,
                .disappearingMessageConfigUpdateNotExpectedSDSRecordType,
                .disappearingMessageConfigUpdateMissingAuthor,
                .profileChangeUpdateMissingAuthor,
                .profileChangeUpdateMissingNames,
                .threadMergeUpdateMissingAuthor,
                .sessionSwitchoverUpdateMissingAuthor,
                .learnedProfileUpdateMissingPreviousName,
                .learnedProfileUpdateInvalidE164,
                .learnedProfileUpdateMissingAuthor,
                .editHistoryFailedToFetch,
                .unableToReadStoryContextAssociatedData,
                .unviewedViewOnceMessageUnexpectedAttachmentCount,
                .adHocCallDoesNotHaveCallLinkAsConversationId,
                .invalidAdHocCallTimestamp,
                .revisionsPresentOnUnexpectedMessage,
                .revisionWasUnexpectedMessage,
                .pollMissing,
                .pollOptionIdMissing,
                .invalidPollRecordDatabaseRow,
                .invalidPollOptionRecordDatabaseRow,
                .invalidPollVoteRecordDatabaseRow,
                .pollMessageMissingQuestionBody,
                .pollVoteAuthorSignalRecipientIdMissing,
                .endPollUpdateInvalidAuthorAci,
                .pollEndMissingQuestion,
                .pollEndMissingPersistableData,
                .pinMessageChatUpdateMissingPersistableData:
                // Log any others as we see them.
                return nil
            }
        }

        public var logLevel: BackupArchive.LogLevel {
            switch type {
            case
                .protoSerializationError,
                .referencedRecipientIdMissing,
                .referencedThreadIdMissing,
                .referencedCustomChatColorMissing,
                .unableToFetchRecipientIdentity,
                .fileIOError,
                .groupMasterKeyError,
                .themedCustomChatColor,
                .distributionListMissingDistributionId,
                .unableToFetchDistributionListRecipients,
                .distributionListHasDefaultViewMode,
                .customDistributionListBlocklistViewMode,
                .distributionListMissingDeletionTimestamp,
                .distributionListInvalidTimestamp,
                .invalidIncomingMessageAuthor,
                .invalidOutgoingMessageRecipient,
                .invalidQuoteAuthor,
                .linkPreviewMissingUrl,
                .storyReplyAuthorMissingAci,
                .storyReplyEmptyContents,
                .storyReplyInGroupThread,
                .invalidReactionAddress,
                .invalidReactionTimestamp,
                .emptyGroupUpdate,
                .missingLocalProfile,
                .missingLocalProfileKey,
                .missingRequiredGroupMemberParams,
                .groupCallRecordHadInvalidCallStatus,
                .verificationStateUpdateInteractionMissingAuthor,
                .phoneNumberChangeInteractionMissingAuthor,
                .identityKeyChangeInteractionMissingAuthor,
                .decryptionErrorInteractionMissingAuthor,
                .paymentActivationRequestInteractionMissingAuthor,
                .paymentsActivatedInteractionMissingAuthor,
                .foundComplexChatUpdateTypeWhenExpectingSimple,
                .verificationStateChangeNotExpectedSDSRecordType,
                .unknownProtocolVersionNotExpectedSDSRecordType,
                .simpleChatUpdateMessageNotInContactThread,
                .paymentInfoFetchFailed,
                .missingPaymentInformation,
                .disappearingMessageConfigUpdateNotExpectedSDSRecordType,
                .disappearingMessageConfigUpdateMissingAuthor,
                .profileChangeUpdateMissingAuthor,
                .threadMergeUpdateMissingAuthor,
                .sessionSwitchoverUpdateMissingAuthor,
                .learnedProfileUpdateMissingPreviousName,
                .learnedProfileUpdateInvalidE164,
                .learnedProfileUpdateMissingAuthor,
                .editHistoryFailedToFetch,
                .unableToReadStoryContextAssociatedData,
                .unviewedViewOnceMessageUnexpectedAttachmentCount,
                .adHocCallDoesNotHaveCallLinkAsConversationId,
                .invalidAdHocCallTimestamp,
                .revisionsPresentOnUnexpectedMessage,
                .revisionWasUnexpectedMessage,
                .pollMissing,
                .pollOptionIdMissing,
                .invalidPollRecordDatabaseRow,
                .invalidPollOptionRecordDatabaseRow,
                .invalidPollVoteRecordDatabaseRow,
                .pollMessageMissingQuestionBody,
                .pollVoteAuthorSignalRecipientIdMissing,
                .endPollUpdateInvalidAuthorAci,
                .pollEndMissingQuestion,
                .pollEndMissingPersistableData,
                .pinMessageChatUpdateMissingPersistableData:
                return .error
            case .invalidInteractionDatabaseRow:
                // We've seen real world databases with interaction rows that
                // failed to deserialize into TSInteraciton instances. We'll
                // drop them from the Backup.
                return .warning
            case .contactThreadMissingAddress:
                // We've seen real-world databases with TSContactThreads that
                // have no contact identifiers (aci/pni/e64).
                // These cause us to drop the TSThread from the backup, but
                // we can mark these as warnings. If these threads have
                // any messages in them, those will fail at log level error.
                return .warning
            case .profileChangeUpdateMissingNames:
                // We've seen real world databases with profileChange TSInfoMessages
                // that don't have names on them. We filter these at render time
                // (see `hasRenderableChanges`), so drop them from the backup
                // with a warning but not an error.
                return .warning
            case .quoteTypeNormalMissingTextAndAttachments:
                // We've seen real-world databases with "empty" quotes; we will
                // drop the quote on export and issue a warning.
                return .warning
            case .linkPreviewUrlNotInBody:
                // We've seen real world databases with invalid link previews; we
                // just drop these on export and just issue a warning.
                return .warning
            case .incomingMessageFromSelf:
                // We've seen real world databases with messages from self; we
                // fudge these into outgoing messages on export and issue a warning.
                return .warning
            case .nonSelfAuthorInNoteToSelf:
                // We've seen real world databases with messages from other authors
                // in note to self; we fudge these into outgoing messages on export
                // and issue a warning.
                return .warning
            case .stickerMessageMissingStickerAttachment:
                // We lose a lot of stickers, apparently, in real world testing.
                // Usually not the end of the world.
                return .warning
            case .messageFromOtherRecipientInContactThread:
                // We've seen real world databases, particular with chats
                // that predate the introduction of ACIs, that have
                // mismatches due to missing or hallucinated ACIs on
                // message rows not matching the TSContactThread row.
                return .warning
            }
        }
    }

    /// Error archiving an entire category of frames; not attributable to a
    /// single frame.
    public struct FatalArchivingError: BackupArchive.LoggableError {
        public enum ErrorType {
            /// Error iterating over all SignalRecipients for backup purposes.
            case recipientIteratorError(RawError)

            /// Error iterating over all threads for backup purposes.
            case threadIteratorError(RawError)
            /// We fetched a thread (via the iterator) with no sqlite row id.
            case fetchedThreadMissingRowId

            /// Some unrecognized thread was found when iterating over all threads.
            case unrecognizedThreadType

            /// Error iterating over all interactions for backup purposes.
            case interactionIteratorError(RawError)
            /// We fetched an interaction (via the iterator) with no sqlite row id.
            case fetchedInteractionMissingRowId

            /// Error fetching reactions for a message.
            case reactionIteratorError(RawError)

            /// Error iterating over all sticker packs for backup purposes.
            case stickerPackIteratorError(RawError)

            /// Error iterating over all call link records for backup purposes.
            case callLinkRecordIteratorError(RawError)

            /// Error iterating over all ad hoc calls for backup purposes.
            case adHocCallIteratorError(RawError)

            case oversizedTextCacheFetchError(RawError)

            /// These should never happen; it means some invariant in the backup code
            /// we could not enforce with the type system was broken. Nothing was wrong with
            /// the proto or local database; its the iOS backup code that has a bug somewhere.
            case developerError(OWSAssertionError)
        }

        private let type: ErrorType
        private let file: StaticString
        private let function: StaticString
        private let line: UInt

        /// Create a new error instance.
        ///
        /// Exposed as a static method rather than an initializer to help
        /// callsites have some context without needing to put the exhaustive
        /// (namespaced) type name at each site.
        public static func fatalArchiveError(
            _ type: ErrorType,
            _ file: StaticString = #file,
            _ function: StaticString = #function,
            _ line: UInt = #line,
        ) -> FatalArchivingError {
            return FatalArchivingError(type: type, file: file, function: function, line: line)
        }

        // MARK: BackupArchive.LoggableError

        public var typeLogString: String {
            return "FatalArchiveError: \(String(describing: type))"
        }

        public var idLogString: String {
            return ""
        }

        public var callsiteLogString: String {
            return "\(file):\(function):\(line)"
        }

        public var collapseKey: String? {
            // Log each of these as we see them.
            return nil
        }

        public var logLevel: BackupArchive.LogLevel {
            // All of these are hard errors.
            return .error
        }
    }

    /// Error restoring a frame.
    public struct RestoreFrameError<ProtoIdType: BackupArchive.LoggableId>: BackupArchive.LoggableError {
        public enum ErrorType {
            public enum InvalidProtoDataError {
                /// No ``BackupProto_BackupInfo`` header found.
                case missingBackupInfoHeader
                /// The ``BackupProto_BackupInfo`` has an unsupported version.
                case unsupportedBackupInfoVersion
                /// The ``BackupProto_BackupInfo`` had a missing or invalid MediaRootBackupKey.
                case invalidMediaRootBackupKey

                /// The AccountData frame was missing or not present before other frames.
                case accountDataNotFound
                /// Some recipient identifier being referenced was not present earlier in the backup file.
                case recipientIdNotFound(RecipientId)
                /// Some chat identifier being referenced was not present earlier in the backup file.
                case chatIdNotFound(ChatId)

                /// Could not parse an Aci. Includes the class of the offending proto.
                case invalidAci(protoClass: Any.Type)
                /// Could not parse an Pni. Includes the class of the offending proto.
                case invalidPni(protoClass: Any.Type)
                /// Could not parse an Aci. Includes the class of the offending proto.
                case invalidServiceId(protoClass: Any.Type)
                /// Could not parse an E164. Includes the class of the offending proto.
                case invalidE164(protoClass: Any.Type)
                /// Could not parse an ``Aes256Key`` profile key. Includes the class
                /// of the offending proto.
                case invalidProfileKey(protoClass: Any.Type)
                /// Could not parse an ``IdentityKey`` from a contact.
                case invalidContactIdentityKey
                /// An invalid member (group, distribution list, etc) was specified as a distribution list member.  Includes the offending proto
                case invalidDistributionListMember(protoClass: Any.Type)

                /// An invalid member label was associated with a group member.
                case invalidMemberLabel

                /// The backup tier in account settings was set but not able to be parsed by libsignal.
                case invalidBackupTier

                /// A ``BackupProto/Contact`` with no aci, pni, or e164.
                case contactWithoutIdentifiers
                /// A ``BackupProto/Contact`` for the local user. This shouldn't exist.
                case otherContactWithLocalIdentifiers

                /// Some custom chat color identifier being referenced was not present earlier in the backup file.
                case customChatColorNotFound(CustomChatColorId)
                /// A ``BackupProto_ChatStyle/Gradient`` had less than two colors.
                case chatStyleGradientSingleOrNoColors

                /// A directionless chat item was not an update message.
                case directionlessChatItemNotUpdateMessage
                /// A ``BackupProto/ChatItem`` has a missing or invalid dateSent.
                case chatItemInvalidDateSent

                /// A message must come from either an Aci or an E164.
                /// One in the backup did not.
                case incomingMessageNotFromAciOrE164
                /// Outgoing message's `BackupProto_SendStatus` can only be for `BackupProto_Contacts`.
                /// One in the backup was to a group, self recipient, or something else.
                case outgoingNonContactMessageRecipient

                /// `BackupProto_Reaction` must come from either an Aci or an E164.
                /// One in the backup did not.
                case reactionNotFromAciOrE164

                /// A ``BackupProto_StandardMessage`` had neither body text nor any attachments.
                case emptyStandardMessage

                /// A ``BackupProto_DirectStoryReplyMessage`` had an empty text body.
                case directStoryReplyMessageEmpty
                /// A ``BackupProto_DirectStoryReplyMessage`` had an empty text body, but a long-text attachment was present.
                case directStoryReplyMessageEmptyWithLongText
                /// A ``BackupProto_DirectStoryReplyMessage`` author didn't have an aci.
                case directStoryReplyFromNonAci
                /// A ``BackupProto_DirectStoryReplyMessage`` was in a group thread.
                case directStoryReplyInGroupThread

                /// A ``BackupProto_StandardMessage/text`` had inlined oversize text that
                /// was too long (even for oversized text there is a limit).
                case standardMessageWayTooOversizedBody
                /// A ``BackupProto_StandardMessage/longText`` was present despite an inlined
                /// oversize message body (longer than standard message body length). Long text
                /// pointers should only be included if the attachment is undownloaded and unavailable for inlining.
                case longTextStandardMessageWithOversizeBody
                /// A ``BackupProto_StandardMessage/longText`` was present despite an empty
                /// message body (the body text must always be a prefix of the long text)
                case longTextStandardMessageMissingBody

                /// A quoted message had no body, attachment, gift badge, or other
                /// content in its representation of the original being quoted.
                case quotedMessageEmptyContent
                /// The text body in a quoted message was too long (as enforced by standard message sending).
                /// Oversized text in quotes is unsupported on all platforms.
                case quotedMessageOversizeText

                /// A link preview with an empty string for the url
                case linkPreviewEmptyUrl
                /// Link preview urls must be present in the message body;
                /// this error is for when they are not.
                case linkPreviewUrlNotInBody

                /// A ``BackupProto_ContactMessage/contact`` is missing.
                case contactMessageMissingContactAttachment
                /// A ``BackupProto_ContactAttachment/Phone/value`` was missing or empty.
                case contactAttachmentPhoneNumberMissingValue
                /// A ``BackupProto_ContactAttachment/Email/value`` was missing or empty.
                case contactAttachmentEmailMissingValue
                /// A ``BackupProto_ContactAttachment/PostalAddress`` with all empty fields;
                /// at least some field has to be nonempty to be a valid address.
                case contactAttachmentEmptyAddress

                /// A `BackupProto_Group's` gv2 master key could not be parsed by libsignal.
                case invalidGV2MasterKey
                /// A `BackupProto_Group` was missing its group snapshot.
                case missingGV2GroupSnapshot
                /// A ``BackupProtoGroup/BackupProtoMemberPendingProfileKey`` was
                /// missing its member details.
                case invitedGV2MemberMissingMemberDetails
                /// We failed to build a V2 group model while restoring a group.
                case failedToBuildGV2GroupModel

                /// A `BackupProto_GroupChangeChatUpdate` ChatItem with a non-group-chat chatId.
                case groupUpdateMessageInNonGroupChat
                /// A `BackupProto_GroupChangeChatUpdate` ChatItem without any updates!
                case emptyGroupUpdates
                /// A `BackupProto_GroupSequenceOfRequestsAndCancelsUpdate` where
                /// the requester is the local user, which isn't allowed.
                case sequenceOfRequestsAndCancelsWithLocalAci

                /// A profile key for the local user that could not be parsed into a valid aes256 key
                case invalidLocalProfileKey
                /// A profile key for the local user that could not be parsed into a valid aes256 key
                case invalidLocalUsernameLink

                /// A `BackupProto_IndividualCall` chat item update was associated
                /// with a thread that was not a contact thread.
                case individualCallNotInContactThread

                /// A `BackupProto_GroupCall` chat item update was associated with
                /// a thread that was not a group thread.
                case groupCallNotInGroupThread
                /// A `BackupProto_GroupCall` referenced a recipient that was not
                /// a contact or otherwise did not contain an ACI.
                case groupCallRecipientIdNotAnAci(RecipientId)

                /// `BackupProto_DistributionList.distributionId` was not a valid UUID
                case invalidDistributionListId
                /// A custom (non-MyStory) distribution list had ``BackupProto_DistributionList/PrivacyMode/all``
                /// or ``BackupProto_DistributionList/PrivacyMode/allExcept``, which are only allowed
                /// for My Story.
                case customDistributionListPrivacyModeAllOrAllExcept
                /// `BackupProto_DistributionListItem.deletionTimestamp` was invalid
                case invalidDistributionListDeletionTimestamp

                /// ``BackupProto_DistributionListItem`` was used as a recipient for
                /// a ``BackupProto_Chat``; this isn't allowed.
                case distributionListUsedAsChatRecipient
                /// ``BackupProto_CallLink`` was used as a recipient for something
                /// other than a ``BackupProto_AdHocCall``; this isn't allowed.
                case callLinkUsedAsChatRecipient

                /// A "verification state change" simple chat update was
                /// associated with a non-contact recipient.
                case verificationStateChangeNotFromContact
                /// A "phone number chnaged" simple chat update was associated
                /// with a non-contact recipient.
                case phoneNumberChangeNotFromContact
                /// An "end session" simple chat update was associated with a
                /// non-contact recipient.
                case endSessionNotFromContact
                /// A "decryption error" simple chat update was associated with
                /// a non-contact recipient.
                case decryptionErrorNotFromContact
                /// A "payments activation request" simple chat update was
                /// associated with a recipient with no ACI.
                case paymentsActivationRequestNotFromAci
                /// A "payments activated" simple chat update was associated
                /// with a recipient with no ACI.
                case paymentsActivatedNotFromAci
                /// An "unsupported protocol version" simple chat update was
                /// associated with a non-contact recipient.
                case unsupportedProtocolVersionNotFromContact

                /// An ``BackupProto_PaymentNotification`` was sent
                /// in a group chat (not a 1:1 chat).
                case paymentNotificationInGroup

                /// An "expiration timer update" was in a non-contact thread.
                /// - Note
                /// Expiration timer updates for group threads are handled via
                /// a separate "group expiration timer update" proto.
                case expirationTimerUpdateNotInContactThread

                /// An "expiration timer" field contained a value that
                /// overflowed the local type for expiration timers.
                case expirationTimerOverflowedLocalType

                /// A "profile change update" was not authored by a contact.
                case profileChangeUpdateNotFromContact

                /// A "thread merge update" was not authored by a contact.
                case threadMergeUpdateNotFromContact

                /// A "session switchover update" was not authored by a contact.
                case sessionSwitchoverUpdateNotFromContact

                /// A "learned profile update" was not authored by a contact.
                case learnedProfileUpdateNotFromContact

                /// An incoming message, or a revision for an incoming message,
                /// were missing incoming details. (Revisions must have the same
                /// directionality as their parent.)
                case revisionOfIncomingMessageMissingIncomingDetails

                /// An outgoing message, or a revision for an outgoing message,
                /// were missing outgoing details. (Revisions must have the same
                /// directionality as their parent.)
                case revisionOfOutgoingMessageMissingOutgoingDetails

                /// A ``BackupProto_MessageAttachment/clientUuid`` contained an invalid UUID.
                case invalidAttachmentClientUUID

                /// A ``BackupProto_CallLink/rootKey`` was invalid.
                case callLinkInvalidRootKey

                /// The recipient on an ad hoc call was not a call link. No other
                /// recipient types are valid for an ad hoc call.
                case recipientOfAdHocCallWasNotCallLink

                /// The poll terminate message author had an invalid non-contact Address
                case pollTerminateAuthorNotContact

                /// Poll question was empty
                case pollQuestionEmpty

                /// The poll vote message author had an invalid non-contact Address
                case pollVoteAuthorNotContact

                /// We only expect one vote count per author, but there were multiple
                case pollVoteCountRepeated

                /// We expect all authors to have an associated latest vote count, but there wasn't
                case noPollVoteCountForAuthor

                /// The pin message author had an invalid non-contact Address
                case pinMessageAuthorNotContact

                /// There were more pinned messages than allowed
                case invalidNumberOfPinnedMessages

                /// A timestamp to help identify a target message overflowed a local type
                case sentTimestampOverflowedLocalType

                /// The admin delete author had an invalid non-contact address
                case adminDeleteAuthorNotContact
            }

            /// The proto contained invalid or self-contradictory data, e.g an invalid ACI.
            case invalidProtoData(InvalidProtoDataError)

            /// The object being restored depended on a TSThread that should have been created earlier but was not.
            /// This could be either a group or contact thread, we are restoring a frame that doesn't care (e.g. a ChatItem).
            case referencedChatThreadNotFound(ThreadUniqueId)
            /// The object being inserted depended on a TSGroupThread that should have been created earlier but was not.
            /// The overlap with referencedChatThreadNotFound is confusing, but this is for restoring group-specific metadata.
            case referencedGroupThreadNotFound(GroupId)

            /// The object being inserted depended on a CustomChatColor that should have been created earlier but was not.
            case referencedCustomChatColorNotFound(CustomChatColor.Key)

            case databaseModelMissingRowId(modelClass: AnyClass)

            case databaseInsertionFailed(RawError)

            /// We failed to properly create the attachment in the DB after restoring
            case failedToCreateAttachment

            /// These should never happen; it means some invariant we could not
            /// enforce with the type system was broken. Nothing was wrong with
            /// the proto; its the iOS code that has a bug somewhere.
            case developerError(OWSAssertionError)

            /// Poll failed to insert in SQL
            case pollCreateFailedToInsertInDatabase

            /// Poll vote failed to insert in SQL
            case pollVoteFailedToInsertInDatabase

            /// Poll terminate failed to insert in SQL
            case pollTerminateFailedToInsertInDatabase
        }

        private let type: ErrorType
        private let id: ProtoIdType
        private let file: StaticString
        private let function: StaticString
        private let line: UInt

        /// Create a new error instance.
        ///
        /// Exposed as a static method rather than an initializer to help
        /// callsites have some context without needing to put the exhaustive
        /// (namespaced) type name at each site.
        public static func restoreFrameError(
            _ type: ErrorType,
            _ id: ProtoIdType,
            file: StaticString = #file,
            function: StaticString = #function,
            line: UInt = #line,
        ) -> RestoreFrameError {
            return RestoreFrameError(type: type, id: id, file: file, function: function, line: line)
        }

        public var typeLogString: String {
            return "RestoreFrameError: \(String(describing: type))"
        }

        public var idLogString: String {
            return "\(id.typeLogString).\(id.idLogString)"
        }

        public var callsiteLogString: String {
            return "\(file):\(function) line \(line)"
        }

        public var collapseKey: String? {
            switch type {
            case .invalidProtoData(let invalidProtoDataError):
                switch invalidProtoDataError {
                case
                    .missingBackupInfoHeader,
                    .unsupportedBackupInfoVersion,
                    .invalidMediaRootBackupKey,
                    .accountDataNotFound,
                    .recipientIdNotFound,
                    .chatIdNotFound,
                    .invalidBackupTier:
                    // Collapse these by the id they refer to, which is in the "type".
                    return typeLogString
                case .customChatColorNotFound(let id):
                    return id.idLogString
                case
                    .invalidAci,
                    .invalidPni,
                    .invalidServiceId,
                    .invalidE164,
                    .invalidProfileKey,
                    .invalidContactIdentityKey,
                    .invalidDistributionListMember,
                    .invalidMemberLabel,
                    .contactWithoutIdentifiers,
                    .otherContactWithLocalIdentifiers,
                    .chatItemInvalidDateSent,
                    .chatStyleGradientSingleOrNoColors,
                    .directionlessChatItemNotUpdateMessage,
                    .incomingMessageNotFromAciOrE164,
                    .outgoingNonContactMessageRecipient,
                    .reactionNotFromAciOrE164,
                    .emptyStandardMessage,
                    .directStoryReplyMessageEmpty,
                    .directStoryReplyMessageEmptyWithLongText,
                    .directStoryReplyFromNonAci,
                    .directStoryReplyInGroupThread,
                    .standardMessageWayTooOversizedBody,
                    .longTextStandardMessageWithOversizeBody,
                    .longTextStandardMessageMissingBody,
                    .quotedMessageEmptyContent,
                    .quotedMessageOversizeText,
                    .linkPreviewEmptyUrl,
                    .linkPreviewUrlNotInBody,
                    .contactMessageMissingContactAttachment,
                    .contactAttachmentPhoneNumberMissingValue,
                    .contactAttachmentEmailMissingValue,
                    .contactAttachmentEmptyAddress,
                    .invalidGV2MasterKey,
                    .missingGV2GroupSnapshot,
                    .invitedGV2MemberMissingMemberDetails,
                    .failedToBuildGV2GroupModel,
                    .groupUpdateMessageInNonGroupChat,
                    .emptyGroupUpdates,
                    .sequenceOfRequestsAndCancelsWithLocalAci,
                    .invalidLocalProfileKey,
                    .invalidLocalUsernameLink,
                    .individualCallNotInContactThread,
                    .groupCallNotInGroupThread,
                    .groupCallRecipientIdNotAnAci,
                    .invalidDistributionListId,
                    .customDistributionListPrivacyModeAllOrAllExcept,
                    .invalidDistributionListDeletionTimestamp,
                    .distributionListUsedAsChatRecipient,
                    .verificationStateChangeNotFromContact,
                    .phoneNumberChangeNotFromContact,
                    .endSessionNotFromContact,
                    .decryptionErrorNotFromContact,
                    .paymentsActivationRequestNotFromAci,
                    .paymentsActivatedNotFromAci,
                    .paymentNotificationInGroup,
                    .unsupportedProtocolVersionNotFromContact,
                    .expirationTimerUpdateNotInContactThread,
                    .expirationTimerOverflowedLocalType,
                    .profileChangeUpdateNotFromContact,
                    .threadMergeUpdateNotFromContact,
                    .sessionSwitchoverUpdateNotFromContact,
                    .learnedProfileUpdateNotFromContact,
                    .revisionOfIncomingMessageMissingIncomingDetails,
                    .revisionOfOutgoingMessageMissingOutgoingDetails,
                    .invalidAttachmentClientUUID,
                    .callLinkInvalidRootKey,
                    .callLinkUsedAsChatRecipient,
                    .recipientOfAdHocCallWasNotCallLink,
                    .pollTerminateAuthorNotContact,
                    .pollQuestionEmpty,
                    .pollVoteAuthorNotContact,
                    .pollVoteCountRepeated,
                    .noPollVoteCountForAuthor,
                    .pinMessageAuthorNotContact,
                    .invalidNumberOfPinnedMessages,
                    .sentTimestampOverflowedLocalType,
                    .adminDeleteAuthorNotContact:
                    // Collapse all others by the id of the containing frame.
                    return idLogString
                }
            case .referencedChatThreadNotFound, .referencedGroupThreadNotFound, .failedToCreateAttachment:
                // Collapse these by the id they refer to, which is in the "type".
                return typeLogString
            case .referencedCustomChatColorNotFound(let key):
                // Collapse these by the key that isn't found.
                return key.rawValue
            case .databaseModelMissingRowId(let modelClass):
                // Collapse these by the relevant class.
                return "\(modelClass)"
            case
                .databaseInsertionFailed(let rawError):
                // We don't want to re-log every instance of this we see if they repeat.
                // Collapse them by the raw error itself.
                return "\(rawError)"
            case .developerError:
                // Log each of these as we see them.
                return nil
            case .pollCreateFailedToInsertInDatabase,
                 .pollVoteFailedToInsertInDatabase,
                 .pollTerminateFailedToInsertInDatabase:
                return typeLogString
            }
        }

        public var logLevel: BackupArchive.LogLevel {
            switch type {
            case .invalidProtoData(let invalidProtoDataError):
                switch invalidProtoDataError {
                case
                    .missingBackupInfoHeader,
                    .unsupportedBackupInfoVersion,
                    .invalidMediaRootBackupKey,
                    .accountDataNotFound,
                    .recipientIdNotFound,
                    .chatIdNotFound,
                    .invalidAci,
                    .invalidPni,
                    .invalidServiceId,
                    .invalidE164,
                    .invalidProfileKey,
                    .invalidContactIdentityKey,
                    .invalidDistributionListMember,
                    .invalidMemberLabel,
                    .invalidBackupTier,
                    .contactWithoutIdentifiers,
                    .otherContactWithLocalIdentifiers,
                    .chatItemInvalidDateSent,
                    .chatStyleGradientSingleOrNoColors,
                    .customChatColorNotFound,
                    .directionlessChatItemNotUpdateMessage,
                    .incomingMessageNotFromAciOrE164,
                    .outgoingNonContactMessageRecipient,
                    .reactionNotFromAciOrE164,
                    .emptyStandardMessage,
                    .directStoryReplyMessageEmpty,
                    .directStoryReplyMessageEmptyWithLongText,
                    .directStoryReplyFromNonAci,
                    .directStoryReplyInGroupThread,
                    .standardMessageWayTooOversizedBody,
                    .longTextStandardMessageWithOversizeBody,
                    .longTextStandardMessageMissingBody,
                    .quotedMessageOversizeText,
                    .linkPreviewEmptyUrl,
                    .contactMessageMissingContactAttachment,
                    .contactAttachmentPhoneNumberMissingValue,
                    .contactAttachmentEmailMissingValue,
                    .contactAttachmentEmptyAddress,
                    .invalidGV2MasterKey,
                    .missingGV2GroupSnapshot,
                    .invitedGV2MemberMissingMemberDetails,
                    .failedToBuildGV2GroupModel,
                    .groupUpdateMessageInNonGroupChat,
                    .emptyGroupUpdates,
                    .sequenceOfRequestsAndCancelsWithLocalAci,
                    .invalidLocalProfileKey,
                    .invalidLocalUsernameLink,
                    .individualCallNotInContactThread,
                    .groupCallNotInGroupThread,
                    .groupCallRecipientIdNotAnAci,
                    .invalidDistributionListId,
                    .customDistributionListPrivacyModeAllOrAllExcept,
                    .invalidDistributionListDeletionTimestamp,
                    .distributionListUsedAsChatRecipient,
                    .verificationStateChangeNotFromContact,
                    .phoneNumberChangeNotFromContact,
                    .endSessionNotFromContact,
                    .decryptionErrorNotFromContact,
                    .paymentsActivationRequestNotFromAci,
                    .paymentsActivatedNotFromAci,
                    .paymentNotificationInGroup,
                    .unsupportedProtocolVersionNotFromContact,
                    .expirationTimerUpdateNotInContactThread,
                    .expirationTimerOverflowedLocalType,
                    .profileChangeUpdateNotFromContact,
                    .threadMergeUpdateNotFromContact,
                    .sessionSwitchoverUpdateNotFromContact,
                    .learnedProfileUpdateNotFromContact,
                    .revisionOfIncomingMessageMissingIncomingDetails,
                    .revisionOfOutgoingMessageMissingOutgoingDetails,
                    .invalidAttachmentClientUUID,
                    .callLinkInvalidRootKey,
                    .callLinkUsedAsChatRecipient,
                    .recipientOfAdHocCallWasNotCallLink,
                    .pollTerminateAuthorNotContact,
                    .pollQuestionEmpty,
                    .pollVoteAuthorNotContact,
                    .pollVoteCountRepeated,
                    .noPollVoteCountForAuthor,
                    .pinMessageAuthorNotContact,
                    .invalidNumberOfPinnedMessages,
                    .sentTimestampOverflowedLocalType,
                    .adminDeleteAuthorNotContact:
                    return .error
                case .quotedMessageEmptyContent:
                    // It was historically possible to end up with a quote that
                    // had no contents (no body, no OWSAttachmentInfo, not view-once
                    // or a gift badge). The way this renders is as a quote of an
                    // attachment with no preview, just the text "Attachment".
                    return .warning
                case .linkPreviewUrlNotInBody:
                    // Other client platforms had different validation rules
                    // that make it possible to restore what we consider an
                    // invalid link preview. We drop these link previews
                    // without dropping the containing message.
                    return .warning
                }
            case
                .referencedChatThreadNotFound,
                .referencedGroupThreadNotFound,
                .failedToCreateAttachment,
                .referencedCustomChatColorNotFound,
                .databaseModelMissingRowId,
                .databaseInsertionFailed,
                .developerError,
                .pollCreateFailedToInsertInDatabase,
                .pollVoteFailedToInsertInDatabase,
                .pollTerminateFailedToInsertInDatabase:
                return .error
            }
        }
    }
}

// MARK: - Log Collapsing

extension BackupArchive {

    public enum LogLevel: Int {
        /// Log these, but don't pull up the internal
        /// dialog if all errors are warnings.
        case warning
        /// Log these and show the internal dialog if these happen.
        case error
    }
}

extension BackupArchive {
    protocol LoggableError {
        var typeLogString: String { get }
        var idLogString: String { get }
        var callsiteLogString: String { get }

        /// We want to collapse certain logs. Imagine a Chat is missing from a backup; we don't
        /// want to print "Chat 1234 missing" for every message in that chat, that would be thousands
        /// of log lines.
        /// Instead we collapse these similar logs together, keep a count, and log that.
        /// If this is non-nil, we do that collapsing, otherwise we log as-is.
        var collapseKey: String? { get }

        var logLevel: BackupArchive.LogLevel { get }
    }

    struct LoggableErrorAndProto {
        let error: any BackupArchive.LoggableError
        let wasFrameDropped: Bool
        /// Nil for archiving, if we fail to even parse the proto on restore,
        /// or if the feature flag is disabled such that this would be unused.
        let protoJson: String?

        init(
            error: any BackupArchive.LoggableError,
            wasFrameDropped: Bool,
            protoFrame: SwiftProtobuf.Message? = nil,
        ) {
            self.error = error
            self.wasFrameDropped = wasFrameDropped
            // Don't serialize proto frames if we aren't displaying errors.
            if let protoFrame, BuildFlags.Backups.archiveErrorDisplay {
                do {
                    self.protoJson = try String(
                        data: JSONSerialization.data(
                            withJSONObject: JSONSerialization.jsonObject(
                                with: protoFrame.jsonUTF8Data(),
                                options: .mutableContainers,
                            ),
                            options: .prettyPrinted,
                        ),
                        encoding: .utf8,
                    )
                } catch let jsonError {
                    self.protoJson = "Unable to json encode proto: \(jsonError)"
                }
            } else {
                self.protoJson = nil
            }
        }
    }

    static func collapse(_ errors: [LoggableErrorAndProto]) -> [CollapsedErrorLog] {
        var collapsedLogs = OrderedDictionary<String, CollapsedErrorLog>()
        for error in errors {
            let collapseKey = error.error.collapseKey ?? UUID().uuidString

            if var existingLog = collapsedLogs[collapseKey] {
                existingLog.collapse(error)
                collapsedLogs.replace(key: collapseKey, value: existingLog)
            } else {
                let newLog = CollapsedErrorLog(error)
                collapsedLogs.append(key: collapseKey, value: newLog)
            }
        }
        return Array(collapsedLogs.orderedValues)
    }

    fileprivate static let maxCollapsedIdLogCount = 10

    public struct CollapsedErrorLog {
        private let logger: PrefixedLogger

        public private(set) var typeLogString: String
        public private(set) var exampleCallsiteString: String
        public private(set) var exampleProtoFrameJson: String?
        public private(set) var errorCount: UInt = 0
        public private(set) var idLogStrings: [String] = []
        public private(set) var wasFrameDropped: Bool
        public private(set) var logLevel: BackupArchive.LogLevel

        init(_ error: LoggableErrorAndProto) {
            self.logger = PrefixedLogger(prefix: "[Backups]")

            self.typeLogString = error.error.typeLogString
            self.exampleCallsiteString = error.error.callsiteLogString
            self.exampleProtoFrameJson = error.protoJson
            self.wasFrameDropped = error.wasFrameDropped
            self.logLevel = error.error.logLevel
            self.collapse(error)
        }

        mutating func collapse(_ error: LoggableErrorAndProto) {
            self.errorCount += 1
            self.wasFrameDropped = wasFrameDropped || error.wasFrameDropped
            self.logLevel = LogLevel(rawValue: max(self.logLevel.rawValue, error.error.logLevel.rawValue))!
            if exampleProtoFrameJson == nil, let protoJson = error.protoJson {
                self.exampleProtoFrameJson = protoJson
            }
            if idLogStrings.count < BackupArchive.maxCollapsedIdLogCount {
                idLogStrings.append(error.error.idLogString)
            }
        }

        func log() {
            let logString =
                typeLogString + " "
                    + "Dropped frame(s)? \(wasFrameDropped). "
                    + "Repeated \(errorCount) times. "
                    + "from: \(idLogStrings) "
                    + "example callsite: \(exampleCallsiteString)"
            switch logLevel {
            case .warning:
                logger.warn(logString)
            case .error:
                logger.error(logString)
            }
        }
    }
}