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

409 lines
16 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import LibSignalClient
public protocol GroupCallRecordManager {
/// Create or update a group call record with the given parameters.
func createOrUpdateCallRecord(
callId: UInt64,
groupThread: TSGroupThread,
groupThreadRowId: Int64,
callDirection: CallRecord.CallDirection,
groupCallStatus: CallRecord.CallStatus.GroupCallStatus,
callEventTimestamp: UInt64,
shouldSendSyncMessage: Bool,
tx: DBWriteTransaction
) throws
/// Create a group call record with the given parameters.
///
/// - Parameter groupCallRingerAci
/// The group call ringer for this record, if any. Note that this must only
/// be passed if the record will have a ringing status. Otherwise, pass
/// `nil`.
func createGroupCallRecord(
callId: UInt64,
groupCallInteraction: OWSGroupCallMessage,
groupCallInteractionRowId: Int64,
groupThreadRowId: Int64,
callDirection: CallRecord.CallDirection,
groupCallStatus: CallRecord.CallStatus.GroupCallStatus,
groupCallRingerAci: Aci?,
callEventTimestamp: UInt64,
shouldSendSyncMessage: Bool,
tx: DBWriteTransaction
) throws -> CallRecord
/// Update an group existing call record with the given parameters.
///
/// - Parameter newGroupCallRingerAci
/// The new group call ringer for this record, if any. Note that this must
/// only be passed if the record is, or is being updated to, a ringing
/// ringing status. Otherwise, passing `nil` results in no change to the
/// existing group call ringer.
func updateGroupCallRecord(
existingCallRecord: CallRecord,
newCallDirection: CallRecord.CallDirection,
newGroupCallStatus: CallRecord.CallStatus.GroupCallStatus,
newGroupCallRingerAci: Aci?,
callEventTimestamp: UInt64,
shouldSendSyncMessage: Bool,
tx: DBWriteTransaction
)
/// Update the timestamp of the given call record, if the given timestamp is
/// earlier than the one on the call record.
///
/// We may opportunistically learn a group call has started via peek, and
/// then later learn that it in fact started earlier. For example, we may
/// receive a group call update message for that call whose timestamp
/// predates our peek, implying that the call was in fact started before
/// we discovered it. In these scenarios, we prefer the earlier call began
/// time.
func updateCallBeganTimestampIfEarlier(
existingCallRecord: CallRecord,
callEventTimestamp: UInt64,
tx: DBWriteTransaction
)
}
public extension GroupCallRecordManager {
/// Create a group call record for a call discovered via a peek.
///
/// - Note
/// Group calls discovered via peek never result in a sync message.
func createGroupCallRecordForPeek(
callId: UInt64,
groupCallInteraction: OWSGroupCallMessage,
groupCallInteractionRowId: Int64,
groupThreadRowId: Int64,
tx: DBWriteTransaction
) throws -> CallRecord {
try createGroupCallRecord(
callId: callId,
groupCallInteraction: groupCallInteraction,
groupCallInteractionRowId: groupCallInteractionRowId,
groupThreadRowId: groupThreadRowId,
callDirection: .incoming,
groupCallStatus: .generic,
groupCallRingerAci: nil,
callEventTimestamp: groupCallInteraction.timestamp,
shouldSendSyncMessage: false,
tx: tx
)
}
}
public class GroupCallRecordManagerImpl: GroupCallRecordManager {
private let callRecordStore: CallRecordStore
private let interactionStore: InteractionStore
private let outgoingSyncMessageManager: OutgoingCallEventSyncMessageManager
private let statusTransitionManager: GroupCallRecordStatusTransitionManager
private var logger: CallRecordLogger { .shared }
init(
callRecordStore: CallRecordStore,
interactionStore: InteractionStore,
outgoingSyncMessageManager: OutgoingCallEventSyncMessageManager
) {
self.callRecordStore = callRecordStore
self.interactionStore = interactionStore
self.outgoingSyncMessageManager = outgoingSyncMessageManager
self.statusTransitionManager = GroupCallRecordStatusTransitionManager()
}
public func createOrUpdateCallRecord(
callId: UInt64,
groupThread: TSGroupThread,
groupThreadRowId: Int64,
callDirection: CallRecord.CallDirection,
groupCallStatus: CallRecord.CallStatus.GroupCallStatus,
callEventTimestamp: UInt64,
shouldSendSyncMessage: Bool,
tx: DBWriteTransaction
) throws {
// We never have a group call ringer in this flow.
let groupCallRingerAci: Aci? = nil
switch callRecordStore.fetch(
callId: callId,
conversationId: .thread(threadRowId: groupThreadRowId),
tx: tx
) {
case .matchDeleted:
logger.warn("Ignoring: existing record was deleted!")
case .matchFound(let existingCallRecord):
updateGroupCallRecord(
existingCallRecord: existingCallRecord,
newCallDirection: callDirection,
newGroupCallStatus: groupCallStatus,
newGroupCallRingerAci: groupCallRingerAci,
callEventTimestamp: callEventTimestamp,
shouldSendSyncMessage: shouldSendSyncMessage,
tx: tx
)
case .matchNotFound:
let (newGroupCallInteraction, interactionRowId) = interactionStore.insertGroupCallInteraction(
groupThread: groupThread,
callEventTimestamp: callEventTimestamp,
tx: tx
)
_ = try createGroupCallRecord(
callId: callId,
groupCallInteraction: newGroupCallInteraction,
groupCallInteractionRowId: interactionRowId,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
groupCallRingerAci: groupCallRingerAci,
callEventTimestamp: callEventTimestamp,
shouldSendSyncMessage: shouldSendSyncMessage,
tx: tx
)
}
}
public func createGroupCallRecord(
callId: UInt64,
groupCallInteraction _: OWSGroupCallMessage,
groupCallInteractionRowId: Int64,
groupThreadRowId: Int64,
callDirection: CallRecord.CallDirection,
groupCallStatus: CallRecord.CallStatus.GroupCallStatus,
groupCallRingerAci: Aci?,
callEventTimestamp: UInt64,
shouldSendSyncMessage: Bool,
tx: DBWriteTransaction
) throws -> CallRecord {
let newCallRecord = CallRecord(
callId: callId,
interactionRowId: groupCallInteractionRowId,
threadRowId: groupThreadRowId,
callType: .groupCall,
callDirection: callDirection,
callStatus: .group(groupCallStatus),
groupCallRingerAci: groupCallRingerAci,
callBeganTimestamp: callEventTimestamp
)
let insertResult = Result.init(catching: { try callRecordStore.insert(callRecord: newCallRecord, tx: tx) })
if shouldSendSyncMessage {
outgoingSyncMessageManager.sendSyncMessage(
callRecord: newCallRecord,
callEvent: .callUpdated,
callEventTimestamp: callEventTimestamp,
tx: tx
)
}
try insertResult.get()
return newCallRecord
}
public func updateGroupCallRecord(
existingCallRecord: CallRecord,
newCallDirection: CallRecord.CallDirection,
newGroupCallStatus: CallRecord.CallStatus.GroupCallStatus,
newGroupCallRingerAci: Aci?,
callEventTimestamp: UInt64,
shouldSendSyncMessage: Bool,
tx: DBWriteTransaction
) {
guard case let .group(groupCallStatus) = existingCallRecord.callStatus else {
logger.error("Missing group call status while trying to update record!")
return
}
var newGroupCallStatus = newGroupCallStatus
/// Any time we're updating a group call record, we should check for a
/// call-began timestamp earlier than the one we're aware of.
updateCallBeganTimestampIfEarlier(
existingCallRecord: existingCallRecord,
callEventTimestamp: callEventTimestamp,
tx: tx
)
if existingCallRecord.callDirection != newCallDirection {
callRecordStore.updateDirection(
callRecord: existingCallRecord,
newCallDirection: newCallDirection,
tx: tx
)
}
switch statusTransitionManager.isStatusTransitionAllowed(
fromGroupCallStatus: groupCallStatus,
toGroupCallStatus: newGroupCallStatus
) {
case .allowed:
break
case .notAllowed:
logger.warn("Status transition \(groupCallStatus) -> \(newGroupCallStatus) not allowed. Skipping record update.")
return
case .preferAlternateStatus(let alternateGroupCallStatus):
newGroupCallStatus = alternateGroupCallStatus
}
callRecordStore.updateCallAndUnreadStatus(
callRecord: existingCallRecord,
newCallStatus: .group(newGroupCallStatus),
tx: tx
)
// Important to do this after we update the record status, since we need
// the record to be in a "ringing"-related state before setting this.
if let newGroupCallRingerAci {
callRecordStore.updateGroupCallRingerAci(
callRecord: existingCallRecord,
newGroupCallRingerAci: newGroupCallRingerAci,
tx: tx
)
}
if shouldSendSyncMessage {
outgoingSyncMessageManager.sendSyncMessage(
callRecord: existingCallRecord,
callEvent: .callUpdated,
callEventTimestamp: callEventTimestamp,
tx: tx
)
}
}
public func updateCallBeganTimestampIfEarlier(
existingCallRecord: CallRecord,
callEventTimestamp: UInt64,
tx: DBWriteTransaction
) {
guard callEventTimestamp < existingCallRecord.callBeganTimestamp else {
return
}
callRecordStore.updateCallBeganTimestamp(
callRecord: existingCallRecord,
callBeganTimestamp: callEventTimestamp,
tx: tx
)
}
}
// MARK: -
class GroupCallRecordStatusTransitionManager {
typealias GroupCallStatus = CallRecord.CallStatus.GroupCallStatus
enum TransitionQueryResult {
case allowed
case notAllowed
case preferAlternateStatus(GroupCallStatus)
}
init() {}
/// Whether a ``CallRecord`` is allowed to transition from/to the given
/// group call statuses. If the transition is not allowed, an alternative
/// allowed `toGroupCallStatus` may be returned and should be used by the
/// caller going forward.
func isStatusTransitionAllowed(
fromGroupCallStatus: GroupCallStatus,
toGroupCallStatus: GroupCallStatus
) -> TransitionQueryResult {
switch fromGroupCallStatus {
case .generic:
switch toGroupCallStatus {
case .generic: return .notAllowed
case .joined:
// User joined a call started without ringing.
return .allowed
case .ringing, .ringingAccepted, .ringingDeclined, .ringingMissed, .ringingMissedNotificationProfile:
// This probably indicates a race between us opportunistically
// learning about a call (e.g., by peeking), and receiving a
// ring for that call. That's fine, but we prefer the
// ring-related status.
return .allowed
}
case .joined:
switch toGroupCallStatus {
case .joined: return .notAllowed
case .generic:
// Prefer the fact that we joined somewhere.
return .notAllowed
case .ringing, .ringingDeclined, .ringingMissed, .ringingMissedNotificationProfile:
// We know it's a ringing call, and we joined it, so we'll treat
// it as ringing accepted. This may indicate a race between a
// ring-related event (e.g., a canceled ring, or declining on
// another device) and us joining on this device.
return .preferAlternateStatus(.ringingAccepted)
case .ringingAccepted:
// This probably indicates a race between us opportunistically
// joining about a call, and receiving a ring for that call.
// That's fine, but we prefer the ring-related status.
return .allowed
}
case .ringing:
switch toGroupCallStatus {
case .ringing: return .notAllowed
case .generic:
// We know something more specific about the call now.
return .notAllowed
case .joined:
// This is weird because we should be moving to "ringing
// accepted" rather than joined, but if something weird is
// happening we should prefer the joined status.
fallthrough
case .ringingAccepted, .ringingDeclined, .ringingMissed, .ringingMissedNotificationProfile:
return .allowed
}
case .ringingAccepted:
switch toGroupCallStatus {
case .ringingAccepted: return .notAllowed
case .generic, .joined, .ringing, .ringingDeclined, .ringingMissed, .ringingMissedNotificationProfile:
// Prefer the fact that we accepted the ring somewhere.
return .notAllowed
}
case .ringingDeclined:
switch toGroupCallStatus {
case .ringingDeclined: return .notAllowed
case .generic:
return .notAllowed
case .joined:
// We never want to drop the fact that a ring occurred from our
// status, but if we joined a call for which we declined a ring
// we can treat it as an accepted ring instead.
return .preferAlternateStatus(.ringingAccepted)
case .ringing, .ringingMissed, .ringingMissedNotificationProfile:
// Prefer the more specific status.
return .notAllowed
case .ringingAccepted:
// Prefer the fact that we accepted the ring somewhere.
return .allowed
}
case .ringingMissed, .ringingMissedNotificationProfile:
switch toGroupCallStatus {
case .ringingMissed, .ringingMissedNotificationProfile: return .notAllowed
case .generic:
// Prefer the ring-related status.
return .notAllowed
case .joined:
// We never want to drop the fact that a ring occurred from our
// status, but if we joined a call for which we missed a ring
// we can treat it as an accepted ring instead.
return .preferAlternateStatus(.ringingAccepted)
case .ringing:
// Prefer the more specific status.
return .notAllowed
case .ringingAccepted, .ringingDeclined:
// Prefer the explicit ring-related status.
return .allowed
}
}
}
}