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

public import GRDB
public import LibSignalClient

/// Represents a record of a call, either 1:1 or in a group.
///
/// Powers both "call disposition" (i.e., sending sync messages for call-related
/// events) as well as the Calls Tab.
public final class CallRecord: Codable, PersistableRecord, FetchableRecord {

    /// A device-local, unique identifier for this call.
    ///
    /// All calls have a call ID, which is shared with RingRTC and across
    /// clients. In a 1:1 call this value is generated by the caller, and for
    /// group calls it's derived from the server-generated era ID for the call.
    /// Call IDs are a simple `UInt64`, however, so while collisions should be
    /// rare they are plausible.
    ///
    /// In order to more confidently globally-uniquely identify a call, we pair
    /// its call ID with a reference to the conversation in which the call took
    /// place. Locally to the iOS client, we use the SQLite row ID of the call's
    /// thread, paired with the call ID, to create that unique identifier.
    ///
    /// - Note
    /// When communicating this call's unique ID across clients we send the call
    /// ID along with a shared identifier for the conversation; e.g., a
    /// `ServiceId` for 1:1 calls, the shared group ID for group calls, etc.
    public struct ID: Hashable {
        public let conversationId: ConversationID
        public let callId: UInt64

        public init(conversationId: ConversationID, callId: UInt64) {
            self.conversationId = conversationId
            self.callId = callId
        }
    }

    /// A device-local, unique identifier for this call.
    ///
    /// - SeeAlso ``ID``
    public var id: ID {
        return ID(conversationId: conversationId, callId: callId)
    }

    // MARK: -

    public static let databaseTableName: String = "CallRecord"

    public enum CodingKeys: String, CodingKey {
        case sqliteRowId = "id"
        case callIdString = "callId"
        case interactionRowId
        case threadRowId
        case callLinkRowId
        case callType = "type"
        case callDirection = "direction"
        case callStatus = "status"
        case unreadStatus
        case groupCallRingerAci
        case callBeganTimestamp
        case callEndedTimestamp
    }

    /// This record's SQLite row ID, if it represents a record that has already
    /// been inserted.
    public internal(set) var sqliteRowId: Int64?

    /// Part of the unique ID of this call, shared across clients.
    ///
    /// - SeeAlso ``ID`` and ``id``
    public let callId: UInt64

    public enum ConversationID: Equatable, Hashable {
        /// The SQLite row ID of the thread this call belongs to.
        case thread(threadRowId: Int64)
        case callLink(callLinkRowId: Int64)
    }

    public enum InteractionReference: Equatable {
        /// The SQLite row IDs of the thread/interaction for this call.
        ///
        /// Every ``CallRecord`` in a thread has an associated interaction, which is
        /// used to render call events. These interactions will be either a
        /// ``TSCall`` or ``OWSGroupCallMessage``.
        ///
        /// Some state may be duplicated between a ``CallRecord`` and its
        /// corresponding interaction; however, the ``CallRecord`` should be
        /// considered the source of truth.
        case thread(threadRowId: Int64, interactionRowId: Int64)
        case none
    }

    public let conversationId: ConversationID
    public let interactionReference: InteractionReference

    public let callType: CallType
    public internal(set) var callDirection: CallDirection
    public internal(set) var callStatus: CallStatus

    /// The "unread" status of this call, which is used for app icon and Calls
    /// Tab badging.
    ///
    /// - Note
    /// Only missed calls should ever be in an unread state. All other calls
    /// should have already been marked as read.
    ///
    /// - SeeAlso: ``CallRecord/CallStatus/isMissedCall``
    /// - SeeAlso: ``CallRecordStore/updateCallAndUnreadStatus(callRecord:newCallStatus:tx:)``
    public internal(set) var unreadStatus: CallUnreadStatus

    /// If this record represents a group ring, returns the user that initiated
    /// the ring.
    ///
    /// - Important
    /// This field is only usable if this record represents a group ring.
    public private(set) var groupCallRingerAci: Aci?

    func setGroupCallRingerAci(_ groupCallRingerAci: Aci) {
        guard isGroupRing else {
            CallRecordLogger.shared.error("Set group call ringer, but this record wasn't a group ring!")
            return
        }
        self.groupCallRingerAci = groupCallRingerAci
    }

    /// Does this record represent a group ring?
    private var isGroupRing: Bool {
        switch callStatus {
        case .group(.ringing), .group(.ringingAccepted), .group(.ringingDeclined), .group(.ringingMissed), .group(.ringingMissedNotificationProfile):
            return true
        case .individual, .callLink, .group(.generic), .group(.joined):
            return false
        }
    }

    /// The timestamp at which we believe the call began.
    ///
    /// For calls we discover on this device, such as by receiving a 1:1 call
    /// offer message or a group call ring, this value will be the local
    /// timestamp of the discovery.
    ///
    /// If we receive a message indicating that the call began earlier than we
    /// think it did, this value should reflect the earlier time. This helps
    /// ensure that the view of this call is consistent across our devices, and
    /// across the other participants in the call.
    ///
    /// For example, a linked device may opportunistically join a group call by
    /// peeking it (and send us a sync message about that), before a ring
    /// message for that same call arrives to us. We'll prefer the earlier time
    /// locally, which keeps us in-sync with our linked device.
    ///
    /// In another example, we may discover a group call by peeking at time T,
    /// while processing a message backlog. If that backlog contains a group
    /// call update message for this call indicating it actually began at time
    /// T-1, we'll prefer the earlier time, which keeps us in sync with everyone
    /// else who got that update message.
    ///
    /// This timestamp is intended for comparison between call records, as well
    /// as for display.
    public internal(set) var callBeganTimestamp: UInt64

    /// The date at which we believe the call began.
    ///
    /// - SeeAlso ``callBeganTimestamp``
    public var callBeganDate: Date { Date(millisecondsSince1970: callBeganTimestamp) }

    /// The timestamp at which we believe the call ended, or `0` if unknown.
    ///
    /// - Note
    /// At the time of writing this is only used for group calls in Backups. In
    /// the future, iOS should track this explicitly for both 1:1 and group
    /// calls.
    public internal(set) var callEndedTimestamp: UInt64

    /// Creates a ``CallRecord`` with the given parameters.
    ///
    /// - Note
    /// The ``unreadStatus`` for this call record is automatically derived from
    /// its given call status.
    public init(
        callId: UInt64,
        interactionRowId: Int64,
        threadRowId: Int64,
        callType: CallType,
        callDirection: CallDirection,
        callStatus: CallStatus,
        groupCallRingerAci: Aci? = nil,
        callBeganTimestamp: UInt64,
        callEndedTimestamp: UInt64 = 0,
    ) {
        self.callId = callId
        self.conversationId = .thread(threadRowId: threadRowId)
        self.interactionReference = .thread(threadRowId: threadRowId, interactionRowId: interactionRowId)
        self.callType = callType
        self.callDirection = callDirection
        self.callStatus = callStatus
        self.unreadStatus = CallUnreadStatus(callStatus: callStatus)
        self.callBeganTimestamp = callBeganTimestamp
        self.callEndedTimestamp = callEndedTimestamp

        if let groupCallRingerAci, isGroupRing {
            self.groupCallRingerAci = groupCallRingerAci
        }
    }

    public init(
        callId: UInt64,
        callLinkRowId: Int64,
        callStatus: CallStatus.CallLinkCallStatus,
        callBeganTimestamp: UInt64,
    ) {
        self.callId = callId
        self.conversationId = .callLink(callLinkRowId: callLinkRowId)
        self.interactionReference = .none
        self.callType = .adHocCall
        self.callDirection = .incoming
        self.callStatus = .callLink(callStatus)
        self.unreadStatus = .read
        self.callBeganTimestamp = callBeganTimestamp
        self.callEndedTimestamp = 0
        self.groupCallRingerAci = nil
    }

    /// Capture the SQLite row ID for this record, after insertion.
    public func didInsert(with rowID: Int64, for column: String?) {
        sqliteRowId = rowID
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.sqliteRowId = try container.decode(Int64.self, forKey: .sqliteRowId)
        // We store this as a String because SQLite stores values as Int64 and we've had issues with UInt64 and GRDB.
        self.callId = UInt64(try container.decode(String.self, forKey: .callIdString))!
        if let threadRowId = try container.decodeIfPresent(Int64.self, forKey: .threadRowId) {
            self.conversationId = .thread(threadRowId: threadRowId)
            self.interactionReference = .thread(
                threadRowId: threadRowId,
                interactionRowId: try container.decode(Int64.self, forKey: .interactionRowId),
            )
        } else {
            self.conversationId = .callLink(callLinkRowId: try container.decode(Int64.self, forKey: .callLinkRowId))
            self.interactionReference = .none
        }
        self.callType = try container.decode(CallType.self, forKey: .callType)
        self.callDirection = try container.decode(CallDirection.self, forKey: .callDirection)
        self.callStatus = try container.decode(CallStatus.self, forKey: .callStatus)
        self.unreadStatus = try container.decode(CallUnreadStatus.self, forKey: .unreadStatus)
        self.groupCallRingerAci = try container.decodeIfPresent(UUID.self, forKey: .groupCallRingerAci).map(Aci.init(fromUUID:))
        self.callBeganTimestamp = UInt64(bitPattern: try container.decode(Int64.self, forKey: .callBeganTimestamp))
        self.callEndedTimestamp = UInt64(bitPattern: try container.decode(Int64.self, forKey: .callEndedTimestamp))
    }

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(self.sqliteRowId, forKey: .sqliteRowId)
        // We store this as a String because SQLite stores values as Int64 and we've had issues with UInt64 and GRDB.
        try container.encode(String(self.callId), forKey: .callIdString)
        switch self.conversationId {
        case .thread(let threadRowId):
            try container.encode(threadRowId, forKey: .threadRowId)
        case .callLink(let callLinkRowId):
            try container.encode(callLinkRowId, forKey: .callLinkRowId)
        }
        switch self.interactionReference {
        case .thread(threadRowId: _, let interactionRowId):
            try container.encode(interactionRowId, forKey: .interactionRowId)
        case .none:
            break
        }
        try container.encode(self.callType, forKey: .callType)
        try container.encode(self.callDirection, forKey: .callDirection)
        try container.encode(self.callStatus, forKey: .callStatus)
        try container.encode(self.unreadStatus, forKey: .unreadStatus)
        try container.encodeIfPresent(self.groupCallRingerAci?.rawUUID, forKey: .groupCallRingerAci)
        try container.encode(Int64(bitPattern: self.callBeganTimestamp), forKey: .callBeganTimestamp)
        try container.encode(Int64(bitPattern: self.callEndedTimestamp), forKey: .callEndedTimestamp)
    }
}

// MARK: - Accessory types

extension CallRecord {
    public enum CallType: Int, Codable {
        case audioCall = 0
        case videoCall = 1
        case groupCall = 2
        case adHocCall = 3
    }

    public enum CallDirection: Int, Codable, CaseIterable {
        case incoming = 0
        case outgoing = 1
    }

    public enum CallUnreadStatus: Int, Codable {
        case read = 0
        case unread = 1

        init(callStatus: CallStatus) {
            if callStatus.isMissedCall {
                self = .unread
            } else {
                self = .read
            }
        }
    }
}

#if TESTABLE_BUILD

extension CallRecord {
    func matches(
        _ other: CallRecord,
        overridingThreadRowId: Int64? = nil,
    ) -> Bool {
        let otherIdToCompare: CallRecord.ID = {
            if let overridingThreadRowId {
                return CallRecord.ID(
                    conversationId: .thread(threadRowId: overridingThreadRowId),
                    callId: other.callId,
                )
            }

            return other.id
        }()

        let otherConversationIdToCompare: CallRecord.ConversationID = {
            if let overridingThreadRowId {
                return .thread(threadRowId: overridingThreadRowId)
            }
            return other.conversationId
        }()

        if
            id == otherIdToCompare,
            callId == other.callId,
            conversationId == otherConversationIdToCompare,
            callType == other.callType,
            callDirection == other.callDirection,
            callStatus == other.callStatus,
            unreadStatus == other.unreadStatus,
            groupCallRingerAci == other.groupCallRingerAci,
            callBeganTimestamp == other.callBeganTimestamp,
            callEndedTimestamp == other.callEndedTimestamp
        {
            return true
        }

        return false
    }
}

#endif