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/BackupArchiveGroupCallArchiver.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient

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

    private let callRecordStore: CallRecordStore
    private let groupCallRecordManager: GroupCallRecordManager
    private let interactionStore: BackupArchiveInteractionStore

    init(
        callRecordStore: CallRecordStore,
        groupCallRecordManager: GroupCallRecordManager,
        interactionStore: BackupArchiveInteractionStore,
    ) {
        self.callRecordStore = callRecordStore
        self.groupCallRecordManager = groupCallRecordManager
        self.interactionStore = interactionStore
    }

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

        let groupCallState: BackupProto_GroupCall.State
        if let associatedCallRecord {
            switch associatedCallRecord.callStatus {
            case .group(.generic): groupCallState = .generic
            case .group(.joined): groupCallState = .joined
            case .group(.ringing): groupCallState = .ringing
            case .group(.ringingAccepted):
                switch associatedCallRecord.callDirection {
                case .incoming: groupCallState = .accepted
                case .outgoing: groupCallState = .outgoingRing
                }
            case .group(.ringingDeclined): groupCallState = .declined
            case .group(.ringingMissed): groupCallState = .missed
            case .group(.ringingMissedNotificationProfile): groupCallState = .missedNotificationProfile
            case .individual, .callLink:
                return .messageFailure([.archiveFrameError(
                    .groupCallRecordHadInvalidCallStatus,
                    BackupArchive.InteractionUniqueId(interaction: groupCallInteraction),
                )])
            }
        } else {
            // This call predates the introduction of call records.
            groupCallState = .generic
        }

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

        /// The call record will store the best record of when the call began,
        /// since we update its timestamp if we learn the group call started
        /// earlier than we originally learned about it. If there's no call
        /// record, though, we can fall back to the interaction.
        let startedCallTimestamp: UInt64 = associatedCallRecord?.callBeganTimestamp ?? groupCallInteraction.timestamp

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

        var groupCallUpdate = BackupProto_GroupCall()
        groupCallUpdate.state = groupCallState
        groupCallUpdate.startedCallTimestamp = startedCallTimestamp
        if let associatedCallRecord {
            groupCallUpdate.callID = associatedCallRecord.callId
            groupCallUpdate.read = switch associatedCallRecord.unreadStatus {
            case .read: true
            case .unread: false
            }
            BackupArchive.Timestamps.setTimestampIfValid(
                from: associatedCallRecord,
                \.callEndedTimestamp,
                on: &groupCallUpdate,
                \.endedCallTimestamp,
                allowZero: false,
            )
        } else {
            /// This property is non-optional, but we only track it for calls
            /// with an `associatedCallRecord`. For those without, mark them as
            /// read.
            groupCallUpdate.read = true
        }

        if let ringerAci = associatedCallRecord?.groupCallRingerAci {
            switch context.recipientContext.getRecipientId(aci: ringerAci, forInteraction: groupCallInteraction) {
            case .found(let recipientId):
                groupCallUpdate.ringerRecipientID = recipientId.value
            case .missing(let archiveFrameError):
                return .messageFailure([archiveFrameError])
            }
        }

        if let creatorAci = groupCallInteraction.creatorUuid.flatMap({ Aci.parseFrom(aciString: $0) }) {
            switch context.recipientContext.getRecipientId(aci: creatorAci, forInteraction: groupCallInteraction) {
            case .found(let recipientId):
                groupCallUpdate.startedCallRecipientID = recipientId.value
            case .missing(let archiveFrameError):
                return .messageFailure([archiveFrameError])
            }
        }

        var chatUpdateMessage = BackupProto_ChatUpdateMessage()
        chatUpdateMessage.update = .groupCall(groupCallUpdate)

        switch Details.validateAndBuild(
            interactionUniqueId: groupCallInteraction.uniqueInteractionId,
            author: .localUser,
            directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()),
            dateCreated: groupCallInteraction.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 restoreGroupCall(
        _ groupCall: BackupProto_GroupCall,
        chatItem: BackupProto_ChatItem,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreChatUpdateMessageResult {
        let groupThread: TSGroupThread
        switch chatThread.threadType {
        case .groupV2(let _groupThread):
            groupThread = _groupThread
        case .contact:
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.groupCallNotInGroupThread),
                chatItem.id,
            )])
        }

        let startedCallAci: Aci?
        if groupCall.hasStartedCallRecipientID {
            switch context.recipientContext.getAci(
                recipientId: BackupArchive.RecipientId(value: groupCall.startedCallRecipientID),
                forChatItemId: chatItem.id,
            ) {
            case .found(let aci): startedCallAci = aci
            case .missing(let restoreFrameError): return .messageFailure([restoreFrameError])
            }
        } else {
            startedCallAci = nil
        }

        let groupCallInteraction = OWSGroupCallMessage(
            joinedMemberAcis: [],
            creatorAci: startedCallAci.map { AciObjC($0) },
            thread: groupThread,
            sentAtTimestamp: chatItem.dateSent,
        )
        groupCallInteraction.wasRead = groupCall.read

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

        if groupCall.hasCallID {
            let callDirection: CallRecord.CallDirection
            let callStatus: CallRecord.CallStatus.GroupCallStatus
            switch groupCall.state {
            case .unknownState, .UNRECOGNIZED:
                // Fallback to generic
                callDirection = .incoming
                callStatus = .generic
            case .generic:
                callDirection = .incoming
                callStatus = .generic
            case .joined:
                callDirection = .incoming
                callStatus = .joined
            case .ringing:
                callDirection = .incoming
                callStatus = .ringing
            case .accepted:
                callDirection = .incoming
                callStatus = .ringingAccepted
            case .declined:
                callDirection = .incoming
                callStatus = .ringingDeclined
            case .missed:
                callDirection = .incoming
                callStatus = .ringingMissed
            case .missedNotificationProfile:
                callDirection = .incoming
                callStatus = .ringingMissedNotificationProfile
            case .outgoingRing:
                callDirection = .outgoing
                callStatus = .ringingAccepted
            }

            let groupCallRingerAci: Aci?
            if groupCall.hasRingerRecipientID {
                switch context.recipientContext.getAci(
                    recipientId: BackupArchive.RecipientId(value: groupCall.ringerRecipientID),
                    forChatItemId: chatItem.id,
                ) {
                case .found(let aci): groupCallRingerAci = aci
                case .missing(let restoreFrameError): return .messageFailure([restoreFrameError])
                }
            } else {
                groupCallRingerAci = nil
            }

            let callRecord: CallRecord
            do {
                callRecord = try groupCallRecordManager.createGroupCallRecord(
                    callId: groupCall.callID,
                    groupCallInteraction: groupCallInteraction,
                    groupCallInteractionRowId: groupCallInteraction.sqliteRowId!,
                    groupThreadRowId: chatThread.threadRowId,
                    callDirection: callDirection,
                    groupCallStatus: callStatus,
                    groupCallRingerAci: groupCallRingerAci,
                    callEventTimestamp: groupCall.startedCallTimestamp,
                    shouldSendSyncMessage: false,
                    tx: context.tx,
                )
                if groupCall.hasEndedCallTimestamp {
                    try callRecordStore.updateCallEndedTimestamp(
                        callRecord: callRecord,
                        callEndedTimestamp: groupCall.endedCallTimestamp,
                        tx: context.tx,
                    )
                }
                if groupCall.read {
                    try callRecordStore.markAsRead(callRecord: callRecord, tx: context.tx)
                }
            } catch {
                return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
            }
        }

        return .success(())
    }
}

private extension BackupArchive.RecipientRestoringContext {
    enum RecipientIdResult {
        case found(Aci)
        case missing(BackupArchive.RestoreFrameError<BackupArchive.ChatItemId>)
    }

    func getAci(
        recipientId: BackupArchive.RecipientId,
        forChatItemId chatItemId: BackupArchive.ChatItemId,
    ) -> RecipientIdResult {
        guard let recipientAddress: Address = self[recipientId] else {
            return .missing(.restoreFrameError(
                .invalidProtoData(.recipientIdNotFound(recipientId)),
                chatItemId,
            ))
        }

        switch recipientAddress {
        case .localAddress:
            return .found(localIdentifiers.aci)
        case .contact(let contactAddress):
            guard let aci = contactAddress.aci else { fallthrough }
            return .found(aci)
        case .group, .distributionList, .releaseNotesChannel, .callLink:
            return .missing(.restoreFrameError(
                .invalidProtoData(.groupCallRecipientIdNotAnAci(recipientId)),
                chatItemId,
            ))
        }
    }
}