TM-SGNL-iOS/SignalServiceKit/Calls/Individual/CallEventInserter.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

249 lines
9.2 KiB
Swift

//
// 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: SDSAnyWriteTransaction) {
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: SDSAnyWriteTransaction
) {
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.asV2Write
)
}
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.asV2Read) 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: SDSAnyReadTransaction) -> 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.asV2Read
) {
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: SDSAnyReadTransaction
) -> Bool {
guard let callInteraction = 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: SDSAnyWriteTransaction
) {
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.asV2Write
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
}
}