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

public protocol IndividualCallRecordManager {
    /// Updates the call interaction type for the given interaction, and
    /// correspondingly updates the call record for this interaction if one
    /// exists.
    func updateInteractionTypeAndRecordIfExists(
        individualCallInteraction: TSCall,
        individualCallInteractionRowId: Int64,
        contactThread: TSContactThread,
        newCallInteractionType: RPRecentCallType,
        tx: DBWriteTransaction,
    )

    /// Update the call record for the given call interaction's current state,
    /// or create one if none exists.
    func createOrUpdateRecordForInteraction(
        individualCallInteraction: TSCall,
        individualCallInteractionRowId: Int64,
        contactThread: TSContactThread,
        contactThreadRowId: Int64,
        callId: UInt64,
        tx: DBWriteTransaction,
    ) throws

    /// Create a call record for the given interaction's current state.
    func createRecordForInteraction(
        individualCallInteraction: TSCall,
        individualCallInteractionRowId: Int64,
        contactThread: TSContactThread,
        contactThreadRowId: Int64,
        callId: UInt64,
        callType: CallRecord.CallType,
        callDirection: CallRecord.CallDirection,
        individualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
        callEventTimestamp: UInt64,
        shouldSendSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) throws -> CallRecord

    /// Update the given call record.
    func updateRecord(
        contactThread: TSContactThread,
        existingCallRecord: CallRecord,
        newIndividualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
        shouldSendSyncMessage: Bool,
        tx: DBWriteTransaction,
    )
}

public class IndividualCallRecordManagerImpl: IndividualCallRecordManager {
    private let callRecordStore: CallRecordStore
    private let interactionStore: InteractionStore
    private let outgoingSyncMessageManager: OutgoingCallEventSyncMessageManager
    private let statusTransitionManager: IndividualCallRecordStatusTransitionManager

    private var logger: PrefixedLogger { CallRecordLogger.shared }

    init(
        callRecordStore: CallRecordStore,
        interactionStore: InteractionStore,
        outgoingSyncMessageManager: OutgoingCallEventSyncMessageManager,
    ) {
        self.callRecordStore = callRecordStore
        self.interactionStore = interactionStore
        self.outgoingSyncMessageManager = outgoingSyncMessageManager
        self.statusTransitionManager = IndividualCallRecordStatusTransitionManager()
    }

    public func updateInteractionTypeAndRecordIfExists(
        individualCallInteraction: TSCall,
        individualCallInteractionRowId: Int64,
        contactThread: TSContactThread,
        newCallInteractionType: RPRecentCallType,
        tx: DBWriteTransaction,
    ) {
        guard
            let newIndividualCallStatus = CallRecord.CallStatus.IndividualCallStatus(
                individualCallInteractionType: newCallInteractionType,
            )
        else {
            logger.error("Cannot update interaction or call record, missing or invalid parameters!")
            return
        }

        interactionStore.updateIndividualCallInteractionType(
            individualCallInteraction: individualCallInteraction,
            newCallInteractionType: newCallInteractionType,
            tx: tx,
        )

        guard
            let existingCallRecord = callRecordStore.fetch(
                interactionRowId: individualCallInteractionRowId,
                tx: tx,
            )
        else {
            return
        }

        updateRecord(
            contactThread: contactThread,
            existingCallRecord: existingCallRecord,
            newIndividualCallStatus: newIndividualCallStatus,
            shouldSendSyncMessage: true,
            tx: tx,
        )
    }

    /// Create or update the record for the given interaction, using the latest
    /// state of the interaction.
    ///
    /// Sends a sync message with the latest call record.
    public func createOrUpdateRecordForInteraction(
        individualCallInteraction: TSCall,
        individualCallInteractionRowId: Int64,
        contactThread: TSContactThread,
        contactThreadRowId: Int64,
        callId: UInt64,
        tx: DBWriteTransaction,
    ) throws {
        guard
            let callDirection = CallRecord.CallDirection(
                individualCallInteractionType: individualCallInteraction.callType,
            ),
            let individualCallStatus = CallRecord.CallStatus.IndividualCallStatus(
                individualCallInteractionType: individualCallInteraction.callType,
            )
        else { return }

        switch callRecordStore.fetch(
            callId: callId,
            conversationId: .thread(threadRowId: contactThreadRowId),
            tx: tx,
        ) {
        case .matchDeleted:
            logger.warn("Ignoring: existing record for call was deleted!")
        case .matchFound(let existingCallRecord):
            updateRecord(
                contactThread: contactThread,
                existingCallRecord: existingCallRecord,
                newIndividualCallStatus: individualCallStatus,
                shouldSendSyncMessage: true,
                tx: tx,
            )
        case .matchNotFound:
            _ = try createRecordForInteraction(
                individualCallInteraction: individualCallInteraction,
                individualCallInteractionRowId: individualCallInteractionRowId,
                contactThread: contactThread,
                contactThreadRowId: contactThreadRowId,
                callId: callId,
                callType: CallRecord.CallType(individualCallOfferTypeType: individualCallInteraction.offerType),
                callDirection: callDirection,
                individualCallStatus: individualCallStatus,
                callEventTimestamp: individualCallInteraction.timestamp,
                shouldSendSyncMessage: true,
                tx: tx,
            )
        }
    }

    public func createRecordForInteraction(
        individualCallInteraction: TSCall,
        individualCallInteractionRowId: Int64,
        contactThread: TSContactThread,
        contactThreadRowId: Int64,
        callId: UInt64,
        callType: CallRecord.CallType,
        callDirection: CallRecord.CallDirection,
        individualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
        callEventTimestamp: UInt64,
        shouldSendSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) throws -> CallRecord {
        let callRecord = CallRecord(
            callId: callId,
            interactionRowId: individualCallInteractionRowId,
            threadRowId: contactThreadRowId,
            callType: callType,
            callDirection: callDirection,
            callStatus: .individual(individualCallStatus),
            callBeganTimestamp: callEventTimestamp,
        )

        let insertResult = Result(catching: { try callRecordStore.insert(callRecord: callRecord, tx: tx) })

        if shouldSendSyncMessage {
            outgoingSyncMessageManager.sendSyncMessage(
                callRecord: callRecord,
                callEvent: .callUpdated,
                callEventTimestamp: callRecord.callBeganTimestamp,
                tx: tx,
            )
        }

        try insertResult.get()

        return callRecord
    }

    public func updateRecord(
        contactThread: TSContactThread,
        existingCallRecord: CallRecord,
        newIndividualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
        shouldSendSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) {
        guard case let .individual(individualCallStatus) = existingCallRecord.callStatus else {
            logger.error("Missing individual call status while trying to update record!")
            return
        }

        guard
            statusTransitionManager.isStatusTransitionAllowed(
                fromIndividualCallStatus: individualCallStatus,
                toIndividualCallStatus: newIndividualCallStatus,
            )
        else {
            logger.warn("Status transition \(individualCallStatus) -> \(newIndividualCallStatus) not allowed. Skipping record update.")
            return
        }

        callRecordStore.updateCallAndUnreadStatus(
            callRecord: existingCallRecord,
            newCallStatus: .individual(newIndividualCallStatus),
            tx: tx,
        )

        if shouldSendSyncMessage {
            outgoingSyncMessageManager.sendSyncMessage(
                callRecord: existingCallRecord,
                callEvent: .callUpdated,
                callEventTimestamp: existingCallRecord.callBeganTimestamp,
                tx: tx,
            )
        }
    }
}

private extension CallRecord.CallType {
    init(individualCallOfferTypeType: TSRecentCallOfferType) {
        switch individualCallOfferTypeType {
        case .audio: self = .audioCall
        case .video: self = .videoCall
        }
    }
}

private extension CallRecord.CallDirection {
    init?(
        individualCallInteractionType: RPRecentCallType,
    ) {
        switch individualCallInteractionType {
        case
            .incoming,
            .incomingMissed,
            .incomingDeclined,
            .incomingIncomplete,
            .incomingBusyElsewhere,
            .incomingDeclinedElsewhere,
            .incomingAnsweredElsewhere,
            .incomingMissedBecauseOfDoNotDisturb,
            .incomingMissedBecauseOfChangedIdentity,
            .incomingMissedBecauseBlockedSystemContact:
            self = .incoming
        case
            .outgoing,
            .outgoingIncomplete,
            .outgoingMissed:
            self = .outgoing
        @unknown default:
            CallRecordLogger.shared.warn("Unknown call type!")
            return nil
        }
    }
}

extension CallRecord.CallStatus.IndividualCallStatus {
    public init?(
        individualCallInteractionType: RPRecentCallType,
    ) {
        switch individualCallInteractionType {
        case
            .incomingIncomplete,
            .outgoingIncomplete:
            self = .pending
        case
            .incoming,
            .outgoing,
            .incomingAnsweredElsewhere:
            // The "elsewhere" is a linked device that should be sending us a
            // sync message.
            self = .accepted
        case
            .incomingDeclined,
            .outgoingMissed,
            .incomingDeclinedElsewhere:
            // The "elsewhere" is a linked device that should be sending us a
            // sync message.
            self = .notAccepted
        case
            .incomingMissed,
            .incomingMissedBecauseOfChangedIdentity,
            .incomingMissedBecauseOfDoNotDisturb,
            .incomingMissedBecauseBlockedSystemContact,
            .incomingBusyElsewhere:
            // Note that "busy elsewhere" means we should display the call
            // as missed, but the busy linked device won't send a sync
            // message.
            self = .incomingMissed
        @unknown default:
            CallRecordLogger.shared.warn("Unknown call type!")
            return nil
        }
    }
}

// MARK: -

public class IndividualCallRecordStatusTransitionManager {
    public init() {}

    public func isStatusTransitionAllowed(
        fromIndividualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
        toIndividualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
    ) -> Bool {
        switch fromIndividualCallStatus {
        case .pending:
            switch toIndividualCallStatus {
            case .pending: return false
            case .accepted, .notAccepted, .incomingMissed:
                // Pending can transition to anything.
                return true
            }
        case .accepted:
            switch toIndividualCallStatus {
            case .accepted, .pending: return false
            case .notAccepted, .incomingMissed:
                // Accepted trumps declined or missed.
                return false
            }
        case .notAccepted:
            switch toIndividualCallStatus {
            case .notAccepted, .pending: return false
            case .accepted:
                // Accepted trumps declined...
                return true
            case .incomingMissed:
                // ...but declined trumps missed.
                return false
            }
        case .incomingMissed:
            switch toIndividualCallStatus {
            case .incomingMissed, .pending: return false
            case .accepted, .notAccepted:
                // Accepted or declined trumps missed.
                return true
            }
        }
    }
}