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

684 lines
26 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
/// Handles incoming `CallEvent` sync messages.
///
/// - SeeAlso ``IncomingCallEventSyncMessageParams``
protocol IncomingCallEventSyncMessageManager {
func createOrUpdateRecordForIncomingSyncMessage(
incomingSyncMessage: IncomingCallEventSyncMessageParams,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
)
}
final class IncomingCallEventSyncMessageManagerImpl: IncomingCallEventSyncMessageManager {
private let adHocCallRecordManager: any AdHocCallRecordManager
private let callLinkStore: any CallLinkRecordStore
private let callRecordStore: CallRecordStore
private let callRecordDeleteManager: CallRecordDeleteManager
private let groupCallRecordManager: GroupCallRecordManager
private let individualCallRecordManager: IndividualCallRecordManager
private let interactionDeleteManager: InteractionDeleteManager
private let interactionStore: InteractionStore
private let markAsReadShims: Shims.MarkAsRead
private let recipientDatabaseTable: RecipientDatabaseTable
private let threadStore: ThreadStore
init(
adHocCallRecordManager: any AdHocCallRecordManager,
callLinkStore: any CallLinkRecordStore,
callRecordStore: CallRecordStore,
callRecordDeleteManager: CallRecordDeleteManager,
groupCallRecordManager: GroupCallRecordManager,
individualCallRecordManager: IndividualCallRecordManager,
interactionDeleteManager: InteractionDeleteManager,
interactionStore: InteractionStore,
markAsReadShims: Shims.MarkAsRead,
recipientDatabaseTable: RecipientDatabaseTable,
threadStore: ThreadStore
) {
self.adHocCallRecordManager = adHocCallRecordManager
self.callLinkStore = callLinkStore
self.callRecordStore = callRecordStore
self.callRecordDeleteManager = callRecordDeleteManager
self.groupCallRecordManager = groupCallRecordManager
self.individualCallRecordManager = individualCallRecordManager
self.interactionDeleteManager = interactionDeleteManager
self.interactionStore = interactionStore
self.markAsReadShims = markAsReadShims
self.recipientDatabaseTable = recipientDatabaseTable
self.threadStore = threadStore
}
public func createOrUpdateRecordForIncomingSyncMessage(
incomingSyncMessage syncMessage: IncomingCallEventSyncMessageParams,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
let callId: UInt64 = syncMessage.callId
let callDirection: CallRecord.CallDirection = syncMessage.callDirection
let callType: CallRecord.CallType = syncMessage.conversation.type
let callTimestamp: UInt64 = syncMessage.callTimestamp
let syncMessageConversation = syncMessage.conversation
let syncMessageEvent = syncMessage.callEvent
enum FilteredCallEvent {
case accepted
case notAccepted
}
let logger = CallRecordLogger.shared.suffixed(with: "\(callDirection), \(syncMessageEvent)")
switch syncMessageConversation {
case let .individualThread(contactServiceId, _):
guard
let contactThread = fetchThread(contactServiceId: contactServiceId, tx: tx),
let contactThreadRowId = contactThread.sqliteRowId
else {
logger.error("Missing contact thread for incoming call event sync message!")
return
}
let contactThreadReference = CallRecord.ConversationID.thread(threadRowId: contactThreadRowId)
let filteredSyncMessageEvent: FilteredCallEvent
switch syncMessageEvent {
case .observed:
logger.error("Ignoring OBSERVED event for individual call.")
return
case .deleted:
deleteCallRecordForIncomingSyncMessage(
callId: callId,
conversationId: contactThreadReference,
logger: logger,
tx: tx
)
return
case .accepted:
filteredSyncMessageEvent = .accepted
case .notAccepted:
filteredSyncMessageEvent = .notAccepted
}
let individualCallStatus: CallRecord.CallStatus.IndividualCallStatus = {
switch filteredSyncMessageEvent {
case .accepted: return .accepted
case .notAccepted: return .notAccepted
}
}()
let individualCallInteractionType: RPRecentCallType = {
switch (callDirection, filteredSyncMessageEvent) {
case (.incoming, .accepted): return .incomingAnsweredElsewhere
case (.incoming, .notAccepted): return .incomingDeclinedElsewhere
case (.outgoing, .accepted): return .outgoing
case (.outgoing, .notAccepted): return .outgoingMissed
}
}()
switch callRecordStore.fetch(
callId: callId,
conversationId: contactThreadReference,
tx: tx
) {
case .matchDeleted:
logger.warn(
"Ignoring incoming individual call sync message: existing record was deleted!"
)
return
case .matchFound(let existingCallRecord):
guard let existingCallInteraction: TSCall = interactionStore
.fetchAssociatedInteraction(callRecord: existingCallRecord, tx: tx)
else { return }
logger.info("Updating existing record for individual call sync message.")
updateIndividualCallRecordForIncomingSyncMessage(
existingCallRecord: existingCallRecord,
existingCallInteraction: existingCallInteraction,
existingCallThread: contactThread,
newIndividualCallStatus: individualCallStatus,
newIndividualCallInteractionType: individualCallInteractionType,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
case .matchNotFound:
logger.info("Creating record for individual call sync message.")
createIndividualCallRecordForIncomingSyncMessage(
contactThread: contactThread,
contactThreadRowId: contactThreadRowId,
callId: callId,
callType: callType,
callDirection: callDirection,
individualCallStatus: individualCallStatus,
individualCallInteractionType: individualCallInteractionType,
callTimestamp: callTimestamp,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
case let .groupThread(groupId):
guard
let groupThread = fetchThread(groupId: groupId, tx: tx),
let groupThreadRowId = groupThread.sqliteRowId
else {
logger.error("Missing group thread for incoming call event sync message!")
return
}
let groupThreadReference = CallRecord.ConversationID.thread(threadRowId: groupThreadRowId)
let filteredSyncMessageEvent: FilteredCallEvent
switch syncMessageEvent {
case .observed:
logger.error("Ignoring OBSERVED event for group call.")
return
case .deleted:
deleteCallRecordForIncomingSyncMessage(
callId: callId,
conversationId: groupThreadReference,
logger: logger,
tx: tx
)
return
case .accepted:
filteredSyncMessageEvent = .accepted
case .notAccepted:
filteredSyncMessageEvent = .notAccepted
}
switch callRecordStore.fetch(
callId: callId,
conversationId: groupThreadReference,
tx: tx
) {
case .matchDeleted:
logger.warn(
"Ignoring incoming group call sync message: existing record was deleted!"
)
return
case .matchFound(let existingCallRecord):
guard let existingCallInteraction: OWSGroupCallMessage = interactionStore
.fetchAssociatedInteraction(callRecord: existingCallRecord, tx: tx)
else { return }
guard case let .group(existingCallStatus) = existingCallRecord.callStatus else {
logger.error("Missing group call status for group call record!")
return
}
var newCallDirection = existingCallRecord.callDirection
let newGroupCallStatus: CallRecord.CallStatus.GroupCallStatus
switch filteredSyncMessageEvent {
case .accepted:
switch callDirection {
case .incoming:
// We joined on another device. If we knew about ringing
// on this device we know the ringing was accepted, and
// otherwise it was a non-ringing join.
switch existingCallStatus {
case .generic, .joined:
newGroupCallStatus = .joined
case .ringing, .ringingAccepted, .ringingDeclined, .ringingMissed, .ringingMissedNotificationProfile:
newGroupCallStatus = .ringingAccepted
}
case .outgoing:
if
case .outgoing = existingCallRecord.callDirection,
case .ringingAccepted = existingCallStatus
{
logger.warn("How did we already know about this call?")
return
}
// We rang a group from another device. It's possible we
// opportunistically learned about that call on this
// device via peek (and maybe joined), but this should
// be the first time we're learning about the ring.
switch existingCallStatus {
case .generic, .joined:
newCallDirection = .outgoing
newGroupCallStatus = .ringingAccepted
case .ringing, .ringingAccepted, .ringingDeclined, .ringingMissed, .ringingMissedNotificationProfile:
logger.warn("How did we have a ringing call event for a call we started on another device?")
newGroupCallStatus = .ringingAccepted
}
}
case .notAccepted:
switch callDirection {
case .incoming:
// We declined on another device. If we joined the call
// on this device, we'll prefer the join.
switch existingCallStatus {
case .generic, .ringing, .ringingMissed, .ringingMissedNotificationProfile, .ringingDeclined:
newGroupCallStatus = .ringingDeclined
case .joined, .ringingAccepted:
newGroupCallStatus = .ringingAccepted
}
case .outgoing:
logger.error("How did we decline our own outgoing call?")
return
}
}
logger.info("Updating existing record for group call sync message.")
updateGroupCallRecordForIncomingSyncMessage(
existingCallRecord: existingCallRecord,
existingCallInteraction: existingCallInteraction,
existingGroupThread: groupThread,
newCallDirection: newCallDirection,
newGroupCallStatus: newGroupCallStatus,
callEventTimestamp: callTimestamp,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
case .matchNotFound:
let groupCallStatus: CallRecord.CallStatus.GroupCallStatus
switch (callDirection, filteredSyncMessageEvent) {
case (.outgoing, .notAccepted):
logger.error("How did we decline a call we started?")
return
case (.outgoing, .accepted):
// We don't track the status of outgoing group rings, so
// we'll assume the ringing was accepted.
groupCallStatus = .ringingAccepted
case (.incoming, .accepted):
// Unclear if there was ringing involved. If so, we'll
// update the call record to reflect that if we get the ring
// update.
groupCallStatus = .joined
case (.incoming, .notAccepted):
// We only send this combination for ring declines, so we
// can assume that's what this was.
groupCallStatus = .ringingDeclined
}
logger.info("Creating new record for group call sync message.")
createGroupCallRecordForIncomingSyncMessage(
callId: callId,
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
callEventTimestamp: callTimestamp,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
case .adHoc(let roomId):
guard let callLinkRecord = callLinkRecord(forRoomId: roomId, tx: tx) else {
logger.error("Missing call link record for incoming call event sync message!")
return
}
let newStatus: CallRecord.CallStatus.CallLinkCallStatus
switch syncMessageEvent {
case .notAccepted:
logger.error("Ignoring NOT_ACCEPTED sync message for a call link.")
return
case .deleted:
deleteCallRecordForIncomingSyncMessage(
callId: callId,
conversationId: .callLink(callLinkRowId: callLinkRecord.id),
logger: logger,
tx: tx
)
return
case .accepted:
newStatus = .joined
case .observed:
newStatus = .generic
}
do {
try adHocCallRecordManager.createOrUpdateRecord(
callId: callId,
callLink: callLinkRecord,
status: newStatus,
timestamp: callTimestamp,
shouldSendSyncMessge: false,
tx: tx
)
} catch {
owsFailDebug("\(error)")
return
}
}
}
private func callLinkRecord(forRoomId roomId: Data, tx: DBReadTransaction) -> CallLinkRecord? {
do {
return try callLinkStore.fetch(roomId: roomId, tx: tx)
} catch {
CallRecordLogger.shared.error("Couldn't fetch CallLinkRecord: \(error)")
return nil
}
}
}
// MARK: - Deleting calls
private extension IncomingCallEventSyncMessageManagerImpl {
func deleteCallRecordForIncomingSyncMessage(
callId: UInt64,
conversationId: CallRecord.ConversationID,
logger: PrefixedLogger,
tx: DBWriteTransaction
) {
switch callRecordStore.fetch(
callId: callId,
conversationId: conversationId,
tx: tx
) {
case .matchDeleted:
logger.warn(
"Ignoring incoming delete call sync message: existing record was already deleted!"
)
case .matchFound(let existingCallRecord):
// Don't send a sync message for the call delete: we're already
// reacting to one!
switch conversationId {
case .thread:
interactionDeleteManager.delete(
alongsideAssociatedCallRecords: [existingCallRecord],
sideEffects: .custom(associatedCallDelete: .localDeleteOnly),
tx: tx
)
case .callLink:
callRecordDeleteManager.deleteCallRecords(
[existingCallRecord],
sendSyncMessageOnDelete: false,
tx: tx
)
}
case .matchNotFound:
callRecordDeleteManager.markCallAsDeleted(
callId: callId,
conversationId: conversationId,
tx: tx
)
}
}
}
// MARK: - Individual call
private extension IncomingCallEventSyncMessageManagerImpl {
func updateIndividualCallRecordForIncomingSyncMessage(
existingCallRecord: CallRecord,
existingCallInteraction: TSCall,
existingCallThread: TSContactThread,
newIndividualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
newIndividualCallInteractionType: RPRecentCallType,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
interactionStore.updateIndividualCallInteractionType(
individualCallInteraction: existingCallInteraction,
newCallInteractionType: newIndividualCallInteractionType,
tx: tx
)
individualCallRecordManager.updateRecord(
contactThread: existingCallThread,
existingCallRecord: existingCallRecord,
newIndividualCallStatus: newIndividualCallStatus,
shouldSendSyncMessage: false,
tx: tx
)
markThingsAsReadForIncomingSyncMessage(
callInteraction: existingCallInteraction,
thread: existingCallThread,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
func createIndividualCallRecordForIncomingSyncMessage(
contactThread: TSContactThread,
contactThreadRowId: Int64,
callId: UInt64,
callType: CallRecord.CallType,
callDirection: CallRecord.CallDirection,
individualCallStatus: CallRecord.CallStatus.IndividualCallStatus,
individualCallInteractionType: RPRecentCallType,
callTimestamp: UInt64,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
let newIndividualCallInteraction = TSCall(
callType: individualCallInteractionType,
offerType: callType.individualCallOfferType,
thread: contactThread,
sentAtTimestamp: callTimestamp
)
interactionStore.insertInteraction(newIndividualCallInteraction, tx: tx)
guard let interactionRowId = newIndividualCallInteraction.sqliteRowId else {
owsFail("Missing SQLite row ID for just-inserted interaction!")
}
do {
_ = try individualCallRecordManager.createRecordForInteraction(
individualCallInteraction: newIndividualCallInteraction,
individualCallInteractionRowId: interactionRowId,
contactThread: contactThread,
contactThreadRowId: contactThreadRowId,
callId: callId,
callType: callType,
callDirection: callDirection,
individualCallStatus: individualCallStatus,
// The interaction's timestamp is the call event's timestamp.
callEventTimestamp: newIndividualCallInteraction.timestamp,
shouldSendSyncMessage: false,
tx: tx
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
markThingsAsReadForIncomingSyncMessage(
callInteraction: newIndividualCallInteraction,
thread: contactThread,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
}
// MARK: - Group calls
private extension IncomingCallEventSyncMessageManagerImpl {
func updateGroupCallRecordForIncomingSyncMessage(
existingCallRecord: CallRecord,
existingCallInteraction: OWSGroupCallMessage,
existingGroupThread: TSGroupThread,
newCallDirection: CallRecord.CallDirection,
newGroupCallStatus: CallRecord.CallStatus.GroupCallStatus,
callEventTimestamp: UInt64,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
groupCallRecordManager.updateGroupCallRecord(
existingCallRecord: existingCallRecord,
newCallDirection: newCallDirection,
newGroupCallStatus: newGroupCallStatus,
newGroupCallRingerAci: nil,
callEventTimestamp: callEventTimestamp,
shouldSendSyncMessage: false,
tx: tx
)
markThingsAsReadForIncomingSyncMessage(
callInteraction: existingCallInteraction,
thread: existingGroupThread,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
func createGroupCallRecordForIncomingSyncMessage(
callId: UInt64,
groupThread: TSGroupThread,
groupThreadRowId: Int64,
callDirection: CallRecord.CallDirection,
groupCallStatus: CallRecord.CallStatus.GroupCallStatus,
callEventTimestamp: UInt64,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
let (newGroupCallInteraction, interactionRowId) = interactionStore.insertGroupCallInteraction(
groupThread: groupThread,
callEventTimestamp: callEventTimestamp,
tx: tx
)
do {
_ = try groupCallRecordManager.createGroupCallRecord(
callId: callId,
groupCallInteraction: newGroupCallInteraction,
groupCallInteractionRowId: interactionRowId,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
groupCallRingerAci: nil,
callEventTimestamp: callEventTimestamp,
shouldSendSyncMessage: false,
tx: tx
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
markThingsAsReadForIncomingSyncMessage(
callInteraction: newGroupCallInteraction,
thread: groupThread,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
}
// MARK: -
private extension IncomingCallEventSyncMessageManagerImpl {
func markThingsAsReadForIncomingSyncMessage(
callInteraction: TSInteraction & OWSReadTracking,
thread: TSThread,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
owsPrecondition(callInteraction.uniqueThreadId == thread.uniqueId)
markAsReadShims.markThingsAsReadForIncomingSyncMessage(
callInteraction: callInteraction,
thread: thread,
syncMessageTimestamp: syncMessageTimestamp,
tx: tx
)
}
func fetchThread(
contactServiceId: ServiceId,
tx: DBReadTransaction
) -> TSContactThread? {
guard
let contactRecipient = recipientDatabaseTable.fetchRecipient(
serviceId: contactServiceId, transaction: tx
),
let contactThread = threadStore.fetchContactThread(
recipient: contactRecipient, tx: tx
)
else { return nil }
return contactThread
}
func fetchThread(groupId: Data, tx: DBReadTransaction) -> TSGroupThread? {
return threadStore.fetchGroupThread(groupId: groupId, tx: tx)
}
}
// MARK: -
private extension CallRecord.CallType {
var individualCallOfferType: TSRecentCallOfferType {
switch self {
case .audioCall: return .audio
case .videoCall: return .video
case .groupCall, .adHocCall:
owsFailDebug("Should never ask for an individual call type for a group call!")
return .video
}
}
}
// MARK: - Shims
extension IncomingCallEventSyncMessageManagerImpl {
enum Shims {
typealias MarkAsRead = _IncomingCallEventSyncMessageManagerImpl_MarkAsRead
}
enum ShimsImpl {
typealias MarkAsRead = _IncomingCallEventSyncMessageManagerImpl_MarkAsReadImpl
}
}
protocol _IncomingCallEventSyncMessageManagerImpl_MarkAsRead {
/// Mark a grab-bag of things as read for the given interaction, in response
/// to an incoming call event sync message.
func markThingsAsReadForIncomingSyncMessage(
callInteraction: TSInteraction & OWSReadTracking,
thread: TSThread,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
)
}
final class _IncomingCallEventSyncMessageManagerImpl_MarkAsReadImpl: _IncomingCallEventSyncMessageManagerImpl_MarkAsRead {
private let notificationPresenter: any NotificationPresenter
init(notificationPresenter: any NotificationPresenter) {
self.notificationPresenter = notificationPresenter
}
func markThingsAsReadForIncomingSyncMessage(
callInteraction: TSInteraction & OWSReadTracking,
thread: TSThread,
syncMessageTimestamp: UInt64,
tx: DBWriteTransaction
) {
let tx = SDSDB.shimOnlyBridge(tx)
if !callInteraction.wasRead {
callInteraction.markAsRead(
atTimestamp: syncMessageTimestamp,
thread: thread,
circumstance: .onLinkedDevice,
shouldClearNotifications: true,
transaction: tx
)
}
OWSReceiptManager.markAllCallInteractionsAsReadLocally(
beforeSQLId: callInteraction.grdbId,
thread: thread,
transaction: tx
)
tx.addAsyncCompletionOnMain {
self.notificationPresenter.cancelNotificationsForMissedCalls(
threadUniqueId: thread.uniqueId
)
}
}
}