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

public protocol CallRecordMissedCallManager {
    /// The number of unread missed calls.
    func countUnreadMissedCalls(tx: DBReadTransaction) -> UInt

    /// Marks all unread calls at and before the given timestamp as read.
    ///
    /// - Parameter beforeTimestamp
    /// A timestamp at and before which to mark calls as read. If this value is
    /// `nil`, all calls are marked as read.
    /// - Parameter sendSyncMessage
    /// Whether a sync message should be sent as part of this operation. No sync
    /// message is sent regardless of this value if no calls are actually marked
    /// as read. The sync message will be of type
    /// ``OutgoingCallLogEventSyncMessage/CallLogEvent/EventType/markedAsRead``.
    func markUnreadCallsAsRead(
        beforeTimestamp: UInt64?,
        sendSyncMessage: Bool,
        tx: DBWriteTransaction,
    )

    /// Marks the given call and all unread calls before it in the same
    /// conversation as the given call as read.
    ///
    /// - Parameter beforeCallRecord
    /// The call identifying the conversation and timestamp at and before which
    /// to mark unread calls as read.
    /// - Parameter sendSyncMessage
    /// Whether a sync message should be sent as part of this operation. No sync
    /// message is sent regardless of this value if no calls are actually marked
    /// as read. The sync message will be of type
    /// ``OutgoingCallLogEventSyncMessage/CallLogEvent/EventType/markedAsReadInConversation``.
    func markUnreadCallsInConversationAsRead(
        beforeCallRecord: CallRecord,
        sendSyncMessage: Bool,
        tx: DBWriteTransaction,
    )
}

class CallRecordMissedCallManagerImpl: CallRecordMissedCallManager {
    private let callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter
    private let callRecordQuerier: CallRecordQuerier
    private let callRecordStore: CallRecordStore
    private let syncMessageSender: Shims.SyncMessageSender

    private var logger: CallRecordLogger { .shared }

    init(
        callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter,
        callRecordQuerier: CallRecordQuerier,
        callRecordStore: CallRecordStore,
        syncMessageSender: Shims.SyncMessageSender,
    ) {
        self.callRecordConversationIdAdapter = callRecordConversationIdAdapter
        self.callRecordStore = callRecordStore
        self.callRecordQuerier = callRecordQuerier
        self.syncMessageSender = syncMessageSender
    }

    // MARK: -

    func countUnreadMissedCalls(tx: DBReadTransaction) -> UInt {
        var unreadMissedCallCount: UInt = 0

        for missedCallStatus in CallRecord.CallStatus.missedCalls {
            guard
                let unreadMissedCallCursor = callRecordQuerier.fetchCursorForUnread(
                    callStatus: missedCallStatus,
                    ordering: .descending,
                    tx: tx,
                ) else { continue }

            do {
                while let _ = try unreadMissedCallCursor.next() {
                    unreadMissedCallCount += 1
                }
            } catch {
                owsFailDebug("Unexpectedly failed to iterate CallRecord cursor!")
                continue
            }
        }

        return unreadMissedCallCount
    }

    func markUnreadCallsAsRead(
        beforeTimestamp: UInt64?,
        sendSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) {
        let fetchOrdering = fetchOrdering(forBeforeTimestamp: beforeTimestamp)

        let markedAsReadCount = _markUnreadCallsAsRead(
            fetchOrdering: fetchOrdering,
            threadRowId: nil,
            tx: tx,
        )

        guard markedAsReadCount > 0 else { return }

        logger.info("Marked \(markedAsReadCount) calls as read.")

        if sendSyncMessage {
            /// When doing a bulk mark-as-read, we want to use the newest call
            /// at or before the indicated timestamp (read or not) to populate
            /// the sync message. So, we'll query for a single call, using the
            /// same fetch ordering we used above.
            let mostRecentCall: CallRecord? = try? callRecordQuerier.fetchCursor(
                ordering: fetchOrdering,
                tx: tx,
            )?.next()

            guard let mostRecentCall else {
                owsFailDebug("Unexpectedly failed to get most-recent call after marking calls as read!")
                return
            }

            sendMarkedCallsAsReadSyncMessage(
                callRecord: mostRecentCall,
                eventType: .markedAsRead,
                tx: tx,
            )
        }
    }

    func markUnreadCallsInConversationAsRead(
        beforeCallRecord: CallRecord,
        sendSyncMessage: Bool,
        tx: DBWriteTransaction,
    ) {
        let threadRowId: Int64
        switch beforeCallRecord.conversationId {
        case .thread(let threadRowId2):
            threadRowId = threadRowId2
        case .callLink:
            owsFailDebug("Can't mark call links as read within a conversation.")
            return
        }
        let markedAsReadCount = _markUnreadCallsAsRead(
            fetchOrdering: fetchOrdering(
                forBeforeTimestamp: beforeCallRecord.callBeganTimestamp,
            ),
            threadRowId: threadRowId,
            tx: tx,
        )

        guard markedAsReadCount > 0 else { return }

        logger.info("Marked \(markedAsReadCount) calls as read.")

        if sendSyncMessage {
            sendMarkedCallsAsReadSyncMessage(
                callRecord: beforeCallRecord,
                eventType: .markedAsReadInConversation,
                tx: tx,
            )
        }
    }

    /// Mark calls before or at the given timestamp as read, optionally
    /// considering only calls with the given thread row ID.
    private func _markUnreadCallsAsRead(
        fetchOrdering: CallRecordQuerier.FetchOrdering,
        threadRowId: Int64?,
        tx: DBWriteTransaction,
    ) -> UInt {
        var markedAsReadCount: UInt = 0

        for callStatus in CallRecord.CallStatus.allCases {
            let unreadCallCursor: CallRecordCursor? = {
                if let threadRowId {
                    return callRecordQuerier.fetchCursorForUnread(
                        threadRowId: threadRowId,
                        callStatus: callStatus,
                        ordering: fetchOrdering,
                        tx: tx,
                    )
                } else {
                    return callRecordQuerier.fetchCursorForUnread(
                        callStatus: callStatus,
                        ordering: fetchOrdering,
                        tx: tx,
                    )
                }
            }()

            guard let unreadCallCursor else { continue }

            do {
                let markedAsReadCountBefore = markedAsReadCount

                while let unreadCallRecord = try unreadCallCursor.next() {
                    markedAsReadCount += 1

                    do {
                        try callRecordStore.markAsRead(
                            callRecord: unreadCallRecord,
                            tx: tx,
                        )
                    } catch let error {
                        owsFailBeta("Failed to update call record: \(error)")
                    }
                }

                owsAssertDebug(
                    markedAsReadCount == markedAsReadCountBefore || callStatus.isMissedCall,
                    "Unexpectedly had \(markedAsReadCount - markedAsReadCountBefore) unread calls that were not missed!",
                )
            } catch {
                owsFailDebug("Unexpectedly failed to iterate CallRecord cursor!")
                continue
            }
        }

        return markedAsReadCount
    }

    /// Returns a fetch ordering appropriate for querying calls at or before the
    /// given timestamp. If a `nil` timestamp, all calls will be queried.
    private func fetchOrdering(
        forBeforeTimestamp beforeTimestamp: UInt64?,
    ) -> CallRecordQuerier.FetchOrdering {
        if let beforeTimestamp {
            /// Adjust the timestamp forward one second to catch calls at
            /// this exact timestamp. That's relevant because when we send
            /// this sync message, we do so with the timestamp of an actual
            /// call – and because we (try to) sync call timestamps across
            /// devices, our copy of the call likely has the exact same
            /// timestamp. Without adjusting, we'll skip that call!
            return .descendingBefore(timestamp: beforeTimestamp + 1)
        }

        return .descending
    }

    /// Send a "marked calls as read" sync message with the given event type, so
    /// our other devices can also mark the calls as read.
    ///
    /// - Parameter callRecord
    /// A call record whose timestamp and other parameters will populate the
    /// sync message.
    /// - Parameter eventType
    /// The type of sync message to send.
    private func sendMarkedCallsAsReadSyncMessage(
        callRecord: CallRecord,
        eventType: OutgoingCallLogEventSyncMessage.CallLogEvent.EventType,
        tx: DBWriteTransaction,
    ) {
        let conversationId: Data
        do {
            conversationId = try callRecordConversationIdAdapter.getConversationId(callRecord: callRecord, tx: tx)
        } catch {
            owsFailDebug("\(error)")
            return
        }
        syncMessageSender.sendCallLogEventSyncMessage(
            eventType: eventType,
            callId: callRecord.callId,
            conversationId: conversationId,
            timestamp: callRecord.callBeganTimestamp,
            tx: tx,
        )
    }
}

// MARK: - Mocks

extension CallRecordMissedCallManagerImpl {
    enum Shims {
        typealias SyncMessageSender = _CallRecordMissedCallManagerImpl_SyncMessageSender_Shim
    }

    enum Wrappers {
        typealias SyncMessageSender = _CallRecordMissedCallManagerImpl_SyncMessageSender_Wrapper
    }
}

protocol _CallRecordMissedCallManagerImpl_SyncMessageSender_Shim {
    func sendCallLogEventSyncMessage(
        eventType: OutgoingCallLogEventSyncMessage.CallLogEvent.EventType,
        callId: UInt64,
        conversationId: Data,
        timestamp: UInt64,
        tx: DBWriteTransaction,
    )
}

class _CallRecordMissedCallManagerImpl_SyncMessageSender_Wrapper: _CallRecordMissedCallManagerImpl_SyncMessageSender_Shim {
    private let messageSenderJobQueue: MessageSenderJobQueue

    init(_ messageSenderJobQueue: MessageSenderJobQueue) {
        self.messageSenderJobQueue = messageSenderJobQueue
    }

    func sendCallLogEventSyncMessage(
        eventType: OutgoingCallLogEventSyncMessage.CallLogEvent.EventType,
        callId: UInt64,
        conversationId: Data,
        timestamp: UInt64,
        tx: DBWriteTransaction,
    ) {
        guard let localThread = TSContactThread.getOrCreateLocalThread(transaction: tx) else {
            return
        }

        let outgoingCallLogEventSyncMessage = OutgoingCallLogEventSyncMessage(
            callLogEvent: OutgoingCallLogEventSyncMessage.CallLogEvent(
                eventType: eventType,
                callId: callId,
                conversationId: conversationId,
                timestamp: timestamp,
            ),
            localThread: localThread,
            tx: tx,
        )

        let preparedMessage = PreparedOutgoingMessage.preprepared(
            transientMessageWithoutAttachments: outgoingCallLogEventSyncMessage,
        )
        messageSenderJobQueue.add(
            message: preparedMessage,
            transaction: tx,
        )
    }
}