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

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