357 lines
14 KiB
Swift
357 lines
14 KiB
Swift
//
|
|
// 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
|