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

331 lines
12 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
/// A delegate for managing "accessory" messages in response to change in group
/// call state on the local device.
///
/// In addition to RingRTC messages, we send some additional messages to the
/// group and to our linked devices in response to certain group call state
/// changes, such as joining. This delegate handles those messages, as well as
/// updating the state that powers them, such as the ``CallRecord`` associated
/// with the group call.
///
/// For example, we send "group call update messages" when we join or leave a
/// group, and send "call event sync messages" when we join a call or decline a
/// group ring.
protocol GroupCallAccessoryMessageDelegate: AnyObject, CallServiceStateObserver {
/// Tells the delegate that the local device may have joined a group call.
///
/// This method should be called from any RingRTC callback in which either
/// the local device's ``JoinState`` or the call's era ID change, when the
/// join state is `.joined` and we have an era ID for the call.
///
/// - Important
/// This method must be safe to call repeatedly.
/// - Important
/// This method must be called on the main thread.
/// - Note
/// For the purposes of accessory messages, we consider ourselves to have
/// "joined" a call the first time we find that the local device's join
/// state is `.joined` and we have an era ID for the call.
func localDeviceMaybeJoinedGroupCall(
eraId: String,
groupId: GroupIdentifier,
groupCallRingState: GroupThreadCall.GroupCallRingState
)
/// Tells the delegate that the local device may have left a group call.
///
/// This method should be called from a RingRTC callback whenever the local
/// device state changes such that its ``JoinState`` is not `.joined`.
///
/// - Important
/// This method must be safe to call repeatedly.
/// - Important
/// This method must be called on the main thread.
func localDeviceMaybeLeftGroupCall(
groupId: GroupIdentifier,
groupCall: SignalRingRTC.GroupCall
)
/// Tells the delegate that any group call the local device was joined to
/// has now ended.
///
/// - Important
/// This method must be called on the main thread.
func localDeviceGroupCallDidEnd()
/// Tells the delegate that the local device has declined a group ring.
///
/// - Important
/// This method must be called on the main thread.
func localDeviceDeclinedGroupRing(
ringId: Int64,
groupId: GroupIdentifier
)
}
class GroupCallAccessoryMessageHandler: GroupCallAccessoryMessageDelegate {
private let databaseStorage: SDSDatabaseStorage
private let groupCallRecordManager: GroupCallRecordManager
private let messageSenderJobQueue: MessageSenderJobQueue
private let logger = PrefixedLogger(prefix: "GCAMH")
/// RingRTC does not offer guarantees as to the sequence of callbacks that
/// will be invoked while joining a group call, and we may learn that we are
/// "joined" (for the purposes of accessory messages) from multiple
/// callbacks that may be invoked multiple times with the appropriate
/// "joined" state.
///
/// This property allows us to track whether or not we have already learned
/// that we have joined a group call, when receiving our delegate callbacks.
private var hasJoinedGroupCall: Bool = false
init(
databaseStorage: SDSDatabaseStorage,
groupCallRecordManager: GroupCallRecordManager,
messageSenderJobQueue: MessageSenderJobQueue
) {
self.databaseStorage = databaseStorage
self.groupCallRecordManager = groupCallRecordManager
self.messageSenderJobQueue = messageSenderJobQueue
}
// MARK: - CallServiceStateObserver
func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
guard let oldGroupThreadCall = oldValue.flatMap({ $0.unpackGroupCall() }) else {
// Observers receive updates for all kinds of calls,
// but we only care about group calls here.
return
}
localDeviceMaybeLeftGroupCall(
groupId: oldGroupThreadCall.groupId,
groupCall: oldGroupThreadCall.ringRtcCall
)
localDeviceGroupCallDidEnd()
}
// MARK: - GroupCallAccessoryMessageDelegate
func localDeviceMaybeJoinedGroupCall(
eraId: String,
groupId: GroupIdentifier,
groupCallRingState: GroupThreadCall.GroupCallRingState
) {
AssertIsOnMainThread()
guard !hasJoinedGroupCall else { return }
hasJoinedGroupCall = true
logger.info("Sending join messages for call.")
databaseStorage.asyncWrite { tx in
guard let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx) else {
owsFailDebug("Missing thread for group call.")
return
}
let groupCallUpdateMessage = self.sendGroupCallUpdateMessage(
groupThread: groupThread,
eraId: eraId,
tx: tx
)
// The group call update message is how we tell other members that
// we've joined the call. Correspondingly, we'll make its timestamp
// the "official" timestamp for when we joined the call.
let joinTimestamp = groupCallUpdateMessage.timestamp
self.groupCallRecordManager.createOrUpdateCallRecordForJoin(
eraId: eraId,
groupThread: groupThread,
groupCallRingState: groupCallRingState,
joinTimestamp: joinTimestamp,
tx: tx
)
}
}
func localDeviceMaybeLeftGroupCall(
groupId: GroupIdentifier,
groupCall: SignalRingRTC.GroupCall
) {
AssertIsOnMainThread()
guard hasJoinedGroupCall else { return }
hasJoinedGroupCall = false
logger.info("Sending leave message for call.")
databaseStorage.asyncWrite { tx in
guard let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx) else {
owsFailDebug("Missing thread for group call.")
return
}
_ = self.sendGroupCallUpdateMessage(
groupThread: groupThread,
eraId: groupCall.peekInfo?.eraId,
tx: tx
)
}
}
func localDeviceGroupCallDidEnd() {
AssertIsOnMainThread()
hasJoinedGroupCall = false
}
func localDeviceDeclinedGroupRing(
ringId: Int64,
groupId: GroupIdentifier
) {
AssertIsOnMainThread()
databaseStorage.asyncWrite { tx in
guard let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx) else {
owsFailDebug("Missing thread for group call.")
return
}
self.groupCallRecordManager.createOrUpdateCallRecordForDeclinedRing(
ringId: ringId,
groupThread: groupThread,
tx: tx
)
}
}
// MARK: -
private func sendGroupCallUpdateMessage(
groupThread: TSGroupThread,
eraId: String?,
tx: SDSAnyWriteTransaction
) -> OutgoingGroupCallUpdateMessage {
let updateMessage = OutgoingGroupCallUpdateMessage(
thread: groupThread,
eraId: eraId,
tx: tx
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: updateMessage
)
messageSenderJobQueue.add(
message: preparedMessage, transaction: tx
)
return updateMessage
}
}
private extension GroupCallRecordManager {
private var logger: PrefixedLogger { CallRecordLogger.shared }
/// Create or update a call record in response to the local device joining
/// a group call.
///
/// - Note
/// Joining a group call always sends a sync message.
func createOrUpdateCallRecordForJoin(
eraId: String,
groupThread: TSGroupThread,
groupCallRingState: GroupThreadCall.GroupCallRingState,
joinTimestamp: UInt64,
tx: SDSAnyWriteTransaction
) {
guard let groupThreadRowId = groupThread.sqliteRowId else {
logger.error("Missing SQLite row ID for thread!")
return
}
let callId = callIdFromEra(eraId)
let groupCallStatus: CallRecord.CallStatus.GroupCallStatus
let callDirection: CallRecord.CallDirection
switch groupCallRingState {
case .incomingRing, .incomingRingCancelled:
logger.error("Unexpected group call ring state: \(groupCallRingState)!")
fallthrough
case .ringingEnded:
// Ringing having just ended while joining indicates that we had an
// incoming ring that we've accepted, which has now ended.
groupCallStatus = .ringingAccepted
callDirection = .incoming
case .shouldRing:
// Confusingly, this is the default value for a call's ring state,
// even if we're joining a call someone else started. So, we need to
// treat this case like "joined" - if we want "ringing", we need to
// already actively be ringing.
fallthrough
case .doNotRing:
groupCallStatus = .joined
callDirection = .incoming
case .ringing:
// Being in a currently-ringing call while joining indicates that we
// are the one doing the ringing. We don't track the state of
// outgoing rings, so we'll just treat it as accepted.
groupCallStatus = .ringingAccepted
callDirection = .outgoing
}
logger.info("Creating or updating record for group call join.")
do {
try createOrUpdateCallRecord(
callId: callId,
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
callEventTimestamp: joinTimestamp,
shouldSendSyncMessage: true,
tx: tx.asV2Write
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
}
/// Create or update a call record in response to the local declining a ring
/// for a group call.
///
/// - Note
/// Declining a group ring always sends a sync message.
func createOrUpdateCallRecordForDeclinedRing(
ringId: Int64,
groupThread: TSGroupThread,
tx: SDSAnyWriteTransaction
) {
guard let groupThreadRowId = groupThread.sqliteRowId else {
logger.error("Missing SQLite row ID for thread!")
return
}
logger.info("Creating or updating record for group ring decline.")
do {
try createOrUpdateCallRecord(
callId: callIdFromRingId(ringId),
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: .incoming,
groupCallStatus: .ringingDeclined,
callEventTimestamp: Date().ows_millisecondsSince1970,
shouldSendSyncMessage: true,
tx: tx.asV2Write
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
}
}