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

import Foundation

/// Inserts & updates `TSCall` and `CallRecord` objects for a single call.
///
/// The mutable properties can only be accessed within a write transaction.
public class CallEventInserter {
    private var callRecordStore: any CallRecordStore { DependenciesBridge.shared.callRecordStore }
    private var individualCallRecordManager: any IndividualCallRecordManager { DependenciesBridge.shared.individualCallRecordManager }
    private var interactionStore: any InteractionStore { DependenciesBridge.shared.interactionStore }
    private var notificationPresenter: any NotificationPresenter { SSKEnvironment.shared.notificationPresenterRef }

    private let offerMediaType: TSRecentCallOfferType
    private let thread: TSContactThread
    private let sentAtTimestamp: UInt64

    /// Can be accessed only within write transactions.
    private var callId: UInt64?

    /// Used only for caching.
    /// Can be accessed only within write transactions.
    private var callInteraction: TSCall? {
        didSet {
            assert(oldValue == nil)
        }
    }

    /// Used only for caching.
    /// Can be accessed only within write transactions.
    private var callRecord: CallRecord? {
        didSet {
            assert(oldValue == nil)
        }
    }

    public init(
        thread: TSContactThread,
        callId: UInt64?,
        offerMediaType: TSRecentCallOfferType,
        sentAtTimestamp: UInt64,
    ) {
        self.thread = thread
        self.callId = callId
        self.offerMediaType = offerMediaType
        self.sentAtTimestamp = sentAtTimestamp
    }

    public func setOutgoingCallId(_ callId: UInt64, tx: DBWriteTransaction) {
        self.callId = callId
        if let callInteraction {
            createOrUpdateCallRecordIfNeeded(for: callInteraction, tx: tx)
        } else {
            Logger.info("Unable to create call record with id; no interaction yet")
        }
    }

    /// Finds any existing TSCalls if they exist, or creates a new one and
    /// inserts it into the db if not.
    ///
    /// Looks for TSCalls in the following order:
    /// * Cached in-memory on this object (i.e. we've already dealt with it)
    /// * In the interactions table, using the CallRecord table to bridge by
    /// callId
    ///
    /// If the existing interaction needs to be updated to the new call type,
    /// updates it. *WILL NOT* write other fields, as they are assumed to come
    /// from a linked device that triggered the TSCall to be created and are
    /// therefore canonical.
    public func createOrUpdate(
        callType: RPRecentCallType,
        tx: DBWriteTransaction,
    ) {
        func updateCallType(existingCall: TSCall) {
            guard shouldUpdateCallType(callType, for: existingCall, tx: tx) else {
                return
            }

            guard let existingCallRowId = existingCall.sqliteRowId else {
                owsFailDebug("Missing SQLite row ID for call!")
                return
            }

            individualCallRecordManager.updateInteractionTypeAndRecordIfExists(
                individualCallInteraction: existingCall,
                individualCallInteractionRowId: existingCallRowId,
                contactThread: thread,
                newCallInteractionType: callType,
                tx: tx,
            )
        }

        if let existingCall = self.callInteraction {
            Logger.info("Existing call interaction found, updating")
            updateCallType(existingCall: existingCall)
            return
        }

        if
            // find a matching existing call interaction via call records.
            // this happens if a call event sync message creates the record and
            // interaction before callkit callbacks.
            let callRecord = fetchCallRecord(tx: tx),
            let existingCall = self.interactionStore.fetchAssociatedInteraction(callRecord: callRecord, tx: tx) as TSCall?
        {
            Logger.info("Existing call interaction found on disk, updating")
            self.callInteraction = existingCall
            updateCallType(existingCall: existingCall)
            return
        }

        Logger.info("No existing call interaction found; creating")

        // If we found nothing, create a new interaction.
        let callInteraction = TSCall(
            callType: callType,
            offerType: self.offerMediaType,
            thread: self.thread,
            sentAtTimestamp: self.sentAtTimestamp,
        )
        callInteraction.anyInsert(transaction: tx)
        self.callInteraction = callInteraction
        createOrUpdateCallRecordIfNeeded(for: callInteraction, tx: tx)

        if callInteraction.wasRead {
            // Mark previous unread call interactions as read.
            OWSReceiptManager.markAllCallInteractionsAsReadLocally(
                beforeSQLId: callInteraction.grdbId,
                thread: self.thread,
                transaction: tx,
            )
            let threadUniqueId = self.thread.uniqueId
            DispatchQueue.main.async { [notificationPresenter] in
                notificationPresenter.cancelNotificationsForMissedCalls(threadUniqueId: threadUniqueId)
            }
        }
    }

    private func fetchCallRecord(tx: DBReadTransaction) -> CallRecord? {
        if let callRecord {
            owsAssertDebug(callRecord.callId == callId)
            return callRecord
        }

        guard let callId else {
            return nil
        }

        guard let threadRowId = thread.sqliteRowId else {
            owsFailDebug("Missing SQLite row ID for thread!")
            return nil
        }

        let callRecord: CallRecord? = {
            switch self.callRecordStore.fetch(
                callId: callId,
                conversationId: .thread(threadRowId: threadRowId),
                tx: tx,
            ) {
            case .matchFound(let callRecord):
                return callRecord
            case .matchDeleted, .matchNotFound:
                return nil
            }
        }()

        self.callRecord = callRecord
        return callRecord
    }

    /// Takes a call type to apply to a TSCall, and returns whether or not the
    /// update should be applied. Pass nil for the TSCall if creating a new one.
    ///
    /// We can't blindly update the TSCall's status based on CallKit callbacks.
    /// The status might be set by a linked device via call event syncs, so we
    /// should check that the transition is valid and only update if so.
    /// (e.g. if a linked device picks up as we decline, we should leave it as
    /// accepted)
    private func shouldUpdateCallType(
        _ callType: RPRecentCallType,
        for callInteraction: TSCall?,
        tx: DBReadTransaction,
    ) -> Bool {
        guard let callInteraction else {
            // No further checks if we are creating a new one.
            return true
        }
        // Otherwise we are updated and need to check if transition is valid.
        guard callInteraction.callType != callType else {
            return false
        }
        guard
            let callRecord = fetchCallRecord(tx: tx),
            case let .individual(existingIndividualCallStatus) = callRecord.callStatus,
            let newIndividualCallStatus = CallRecord.CallStatus.IndividualCallStatus(
                individualCallInteractionType: callType,
            )
        else {
            return true
        }
        // Multiple RPRecentCallTypes can map to the same CallRecord status, but
        // transitioning from a CallRecord status to itself is invalid. Catch this
        // case by letting the RPRecentCallType through if it is different (checked
        // above) but the mapped status is the same.
        guard
            existingIndividualCallStatus == newIndividualCallStatus
            || IndividualCallRecordStatusTransitionManager().isStatusTransitionAllowed(
                fromIndividualCallStatus: existingIndividualCallStatus,
                toIndividualCallStatus: newIndividualCallStatus,
            )
        else {
            return false
        }
        return true
    }

    private func createOrUpdateCallRecordIfNeeded(
        for callInteraction: TSCall,
        tx: DBWriteTransaction,
    ) {
        guard let callId else {
            Logger.info("No call id; unable to create call record.")
            return
        }
        Logger.info("Creating or updating call record for interaction: \(callInteraction.callType).")

        guard
            let callInteractionRowId = callInteraction.sqliteRowId,
            let threadRowId = thread.sqliteRowId
        else {
            owsFailDebug("Missing SQLite row IDs for models!")
            return
        }

        do {
            try individualCallRecordManager.createOrUpdateRecordForInteraction(
                individualCallInteraction: callInteraction,
                individualCallInteractionRowId: callInteractionRowId,
                contactThread: thread,
                contactThreadRowId: threadRowId,
                callId: callId,
                tx: tx,
            )
        } catch let error {
            owsFailBeta("Failed to insert call record: \(error)")
        }
    }
}