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

import LibSignalClient

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

    private let callRecordStore: CallRecordStore
    private let individualCallRecordManager: IndividualCallRecordManager
    private let interactionStore: BackupArchiveInteractionStore

    init(
        callRecordStore: CallRecordStore,
        individualCallRecordManager: IndividualCallRecordManager,
        interactionStore: BackupArchiveInteractionStore,
    ) {
        self.callRecordStore = callRecordStore
        self.individualCallRecordManager = individualCallRecordManager
        self.interactionStore = interactionStore
    }

    func archiveIndividualCall(
        _ individualCallInteraction: TSCall,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveChatUpdateMessageResult {
        let associatedCallRecord: CallRecord? = callRecordStore.fetch(
            interactionRowId: individualCallInteraction.sqliteRowId!,
            tx: context.tx,
        )

        var individualCallUpdate = BackupProto_IndividualCall()
        individualCallUpdate.type = { () -> BackupProto_IndividualCall.TypeEnum in
            switch individualCallInteraction.offerType {
            case .audio: return .audioCall
            case .video: return .videoCall
            }
        }()
        individualCallUpdate.direction = { () -> BackupProto_IndividualCall.Direction in
            switch individualCallInteraction.callType {
            case
                .incoming,
                .incomingIncomplete,
                .incomingMissed,
                .incomingMissedBecauseOfChangedIdentity,
                .incomingMissedBecauseOfDoNotDisturb,
                .incomingMissedBecauseBlockedSystemContact,
                .incomingDeclined,
                .incomingDeclinedElsewhere,
                .incomingAnsweredElsewhere,
                .incomingBusyElsewhere:
                return .incoming
            case .outgoing, .outgoingIncomplete, .outgoingMissed:
                return .outgoing
            @unknown default:
                return .unknownDirection
            }
        }()
        individualCallUpdate.state = { () -> BackupProto_IndividualCall.State in
            switch individualCallInteraction.callType {
            case
                .incoming,
                .incomingAnsweredElsewhere,
                .outgoing:
                return .accepted
            case
                .outgoingIncomplete,
                .incomingIncomplete,
                .incomingDeclined,
                .incomingDeclinedElsewhere,
                .incomingBusyElsewhere:
                return .notAccepted
            case
                .incomingMissed,
                .incomingMissedBecauseOfChangedIdentity,
                .incomingMissedBecauseBlockedSystemContact,
                .outgoingMissed:
                return .missed
            case .incomingMissedBecauseOfDoNotDisturb:
                return .missedNotificationProfile
            @unknown default:
                return .unknownState
            }
        }()

        var partialErrors = [BackupArchive.ArchiveFrameError<BackupArchive.InteractionUniqueId>]()

        /// Prefer the call record timestamp if available, since it'll have the
        /// more accurate timestamp. (In practice this won't matter, since for
        /// 1:1 calls the call record takes the same "call started" timestamp as
        /// the interaction: when the call offer message arrives.)
        let startedCallTimestamp = associatedCallRecord?.callBeganTimestamp ?? individualCallInteraction.timestamp

        switch
            BackupArchive.Timestamps.validateTimestamp(startedCallTimestamp)
                .bubbleUp(Details.self, partialErrors: &partialErrors)
        {
        case .continue:
            break
        case .bubbleUpError(let error):
            return error
        }

        individualCallUpdate.startedCallTimestamp = startedCallTimestamp

        if let associatedCallRecord {
            individualCallUpdate.callID = associatedCallRecord.callId
            individualCallUpdate.read = switch associatedCallRecord.unreadStatus {
            case .read: true
            case .unread: false
            }
        } else {
            /// This property is non-optional, but we only track it for calls
            /// with an `associatedCallRecord`. For those without, mark them as
            /// read.
            individualCallUpdate.read = true
        }

        var chatUpdateMessage = BackupProto_ChatUpdateMessage()
        chatUpdateMessage.update = .individualCall(individualCallUpdate)

        switch Details.validateAndBuild(
            interactionUniqueId: individualCallInteraction.uniqueInteractionId,
            author: .localUser,
            directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()),
            dateCreated: individualCallInteraction.timestamp,
            expireStartDate: nil,
            expiresInMs: nil,
            isSealedSender: false,
            chatItemType: .updateMessage(chatUpdateMessage),
            isSmsPreviouslyRestoredFromBackup: false,
            threadInfo: threadInfo,
            pinMessageDetails: nil,
            context: context.recipientContext,
        ).bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let details):
            if partialErrors.isEmpty {
                return .success(details)
            } else {
                return .partialFailure(details, partialErrors)
            }
        case .bubbleUpError(let error):
            return error
        }
    }

    func restoreIndividualCall(
        _ individualCall: BackupProto_IndividualCall,
        chatItem: BackupProto_ChatItem,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreChatUpdateMessageResult {
        let contactThread: TSContactThread
        switch chatThread.threadType {
        case .contact(let _contactThread):
            contactThread = _contactThread
        case .groupV2:
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.individualCallNotInContactThread),
                chatItem.id,
            )])
        }

        let callRecordDirection: CallRecord.CallDirection
        switch individualCall.direction {
        case .unknownDirection, .UNRECOGNIZED:
            // Fallback to incoming
            callRecordDirection = .incoming
        case .incoming:
            callRecordDirection = .incoming
        case .outgoing:
            callRecordDirection = .outgoing
        }

        let callInteractionType: RPRecentCallType
        let callRecordStatus: CallRecord.CallStatus.IndividualCallStatus
        switch (callRecordDirection, individualCall.state) {
        case (.incoming, .accepted), (.incoming, .unknownState), (.incoming, .UNRECOGNIZED):
            callInteractionType = .incoming
            callRecordStatus = .accepted
        case (.incoming, .notAccepted):
            callInteractionType = .incomingDeclined
            callRecordStatus = .notAccepted
        case (.incoming, .missed):
            callInteractionType = .incomingMissed
            callRecordStatus = .incomingMissed
        case (.incoming, .missedNotificationProfile):
            callInteractionType = .incomingMissedBecauseOfDoNotDisturb
            callRecordStatus = .incomingMissed
        case (.outgoing, .accepted), (.outgoing, .unknownState), (.outgoing, .UNRECOGNIZED):
            callInteractionType = .outgoing
            callRecordStatus = .accepted
        case (.outgoing, .notAccepted):
            callInteractionType = .outgoingIncomplete
            callRecordStatus = .notAccepted
        case (.outgoing, .missed), (.outgoing, .missedNotificationProfile):
            callInteractionType = .outgoingMissed
            callRecordStatus = .notAccepted
        }

        let callInteractionOfferType: TSRecentCallOfferType
        let callRecordType: CallRecord.CallType
        switch individualCall.type {
        case .audioCall:
            callInteractionOfferType = .audio
            callRecordType = .audioCall
        case .videoCall:
            callInteractionOfferType = .video
            callRecordType = .videoCall
        case .unknownType, .UNRECOGNIZED:
            // Fallback to audio
            callInteractionOfferType = .audio
            callRecordType = .audioCall
        }

        let callerAci: Aci?
        switch callRecordDirection {
        case .outgoing:
            callerAci = context.recipientContext.localIdentifiers.aci
        case .incoming:
            // Note: we may not _have_ an aci if this call
            // was made before the introduction of acis.
            callerAci = contactThread.contactAddress.aci
        }

        let individualCallInteraction = TSCall(
            callType: callInteractionType,
            offerType: callInteractionOfferType,
            thread: contactThread,
            sentAtTimestamp: chatItem.dateSent,
        )
        individualCallInteraction.wasRead = individualCall.read

        do {
            try interactionStore.insert(
                individualCallInteraction,
                in: chatThread,
                chatId: chatItem.typedChatId,
                callerAci: callerAci,
                wasRead: individualCall.read,
                context: context,
            )
        } catch let error {
            return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
        }

        if individualCall.hasCallID {
            let callRecord: CallRecord
            do {
                callRecord = try individualCallRecordManager.createRecordForInteraction(
                    individualCallInteraction: individualCallInteraction,
                    individualCallInteractionRowId: individualCallInteraction.sqliteRowId!,
                    contactThread: contactThread,
                    contactThreadRowId: chatThread.threadRowId,
                    callId: individualCall.callID,
                    callType: callRecordType,
                    callDirection: callRecordDirection,
                    individualCallStatus: callRecordStatus,
                    callEventTimestamp: individualCall.startedCallTimestamp,
                    shouldSendSyncMessage: false,
                    tx: context.tx,
                )
                if individualCall.read {
                    try callRecordStore.markAsRead(callRecord: callRecord, tx: context.tx)
                }
            } catch {
                return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
            }
        }

        return .success(())
    }
}