594 lines
23 KiB
Swift
594 lines
23 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 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(
|
||
callRecordStore: CallRecordStore,
|
||
callRecordDeleteManager: CallRecordDeleteManager,
|
||
groupCallRecordManager: GroupCallRecordManager,
|
||
individualCallRecordManager: IndividualCallRecordManager,
|
||
interactionDeleteManager: InteractionDeleteManager,
|
||
interactionStore: InteractionStore,
|
||
markAsReadShims: Shims.MarkAsRead,
|
||
recipientDatabaseTable: RecipientDatabaseTable,
|
||
threadStore: ThreadStore
|
||
) {
|
||
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.callType
|
||
let callTimestamp: UInt64 = syncMessage.callTimestamp
|
||
|
||
let syncMessageConversationId = syncMessage.conversationId
|
||
let syncMessageEvent = syncMessage.callEvent
|
||
|
||
let logger = CallRecordLogger.shared.suffixed(with: "\(callDirection), \(syncMessageEvent)")
|
||
|
||
switch syncMessageConversationId {
|
||
case let .individual(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
|
||
}
|
||
|
||
if case .deleted = syncMessageEvent {
|
||
deleteCallRecordForIncomingSyncMessage(
|
||
callId: callId,
|
||
threadRowId: contactThreadRowId,
|
||
logger: logger,
|
||
tx: tx
|
||
)
|
||
return
|
||
}
|
||
|
||
let individualCallStatus: CallRecord.CallStatus.IndividualCallStatus = {
|
||
switch syncMessageEvent {
|
||
case .deleted: owsFail("Checked above – how did we get here?")
|
||
case .accepted: return .accepted
|
||
case .notAccepted: return .notAccepted
|
||
}
|
||
}()
|
||
|
||
let individualCallInteractionType: RPRecentCallType = {
|
||
switch (callDirection, syncMessageEvent) {
|
||
case (_, .deleted): owsFail("Checked above – how did we get here?")
|
||
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,
|
||
threadRowId: contactThreadRowId,
|
||
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 .group(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
|
||
}
|
||
|
||
if case .deleted = syncMessageEvent {
|
||
deleteCallRecordForIncomingSyncMessage(
|
||
callId: callId,
|
||
threadRowId: groupThreadRowId,
|
||
logger: logger,
|
||
tx: tx
|
||
)
|
||
return
|
||
}
|
||
|
||
switch callRecordStore.fetch(
|
||
callId: callId,
|
||
threadRowId: groupThreadRowId,
|
||
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 syncMessageEvent {
|
||
case .deleted:
|
||
owsFail("Checked above – how did we get here?")
|
||
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:
|
||
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:
|
||
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, .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, syncMessageEvent) {
|
||
case (_, .deleted):
|
||
owsFail("Checked above – how did we get here?")
|
||
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
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Deleting calls
|
||
|
||
private extension IncomingCallEventSyncMessageManagerImpl {
|
||
func deleteCallRecordForIncomingSyncMessage(
|
||
callId: UInt64,
|
||
threadRowId: Int64,
|
||
logger: PrefixedLogger,
|
||
tx: DBWriteTransaction
|
||
) {
|
||
switch callRecordStore.fetch(
|
||
callId: callId,
|
||
threadRowId: threadRowId,
|
||
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!
|
||
interactionDeleteManager.delete(
|
||
alongsideAssociatedCallRecords: [existingCallRecord],
|
||
sideEffects: .custom(associatedCallDelete: .localDeleteOnly),
|
||
tx: tx
|
||
)
|
||
case .matchNotFound:
|
||
callRecordDeleteManager.markCallAsDeleted(
|
||
callId: callId,
|
||
threadRowId: threadRowId,
|
||
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!")
|
||
}
|
||
|
||
individualCallRecordManager.createRecordForInteraction(
|
||
individualCallInteraction: newIndividualCallInteraction,
|
||
individualCallInteractionRowId: interactionRowId,
|
||
contactThread: contactThread,
|
||
contactThreadRowId: contactThreadRowId,
|
||
callId: callId,
|
||
callType: callType,
|
||
callDirection: callDirection,
|
||
individualCallStatus: individualCallStatus,
|
||
shouldSendSyncMessage: false,
|
||
tx: tx
|
||
)
|
||
|
||
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(
|
||
groupThread: existingGroupThread,
|
||
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
|
||
)
|
||
|
||
_ = groupCallRecordManager.createGroupCallRecord(
|
||
callId: callId,
|
||
groupCallInteraction: newGroupCallInteraction,
|
||
groupCallInteractionRowId: interactionRowId,
|
||
groupThread: groupThread,
|
||
groupThreadRowId: groupThreadRowId,
|
||
callDirection: callDirection,
|
||
groupCallStatus: groupCallStatus,
|
||
groupCallRingerAci: nil,
|
||
callEventTimestamp: callEventTimestamp,
|
||
shouldSendSyncMessage: false,
|
||
tx: tx
|
||
)
|
||
|
||
markThingsAsReadForIncomingSyncMessage(
|
||
callInteraction: newGroupCallInteraction,
|
||
thread: groupThread,
|
||
syncMessageTimestamp: syncMessageTimestamp,
|
||
tx: tx
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private extension IncomingCallEventSyncMessageManagerImpl {
|
||
func markThingsAsReadForIncomingSyncMessage(
|
||
callInteraction: TSInteraction & OWSReadTracking,
|
||
thread: TSThread,
|
||
syncMessageTimestamp: UInt64,
|
||
tx: DBWriteTransaction
|
||
) {
|
||
owsAssert(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:
|
||
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
|
||
)
|
||
}
|
||
}
|
||
}
|