Signal-iOS/Signal/Calls/GroupThreadCall.swift
Max Radermacher d1a1d2add8
Move SignalCall’s state into the concrete types
Co-authored-by: Harry <109690906+harry-signal@users.noreply.github.com>
2024-05-22 23:20:02 -05:00

300 lines
11 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalRingRTC
import SignalServiceKit
import SignalUI
protocol GroupThreadCallObserver: AnyObject {
func groupCallLocalDeviceStateChanged(_ call: GroupThreadCall)
func groupCallRemoteDeviceStatesChanged(_ call: GroupThreadCall)
func groupCallPeekChanged(_ call: GroupThreadCall)
func groupCallRequestMembershipProof(_ call: GroupThreadCall)
func groupCallRequestGroupMembers(_ call: GroupThreadCall)
func groupCallEnded(_ call: GroupThreadCall, reason: GroupCallEndReason)
func groupCallReceivedReactions(_ call: GroupThreadCall, reactions: [SignalRingRTC.Reaction])
func groupCallReceivedRaisedHands(_ call: GroupThreadCall, raisedHands: [UInt32])
/// Invoked if a call message failed to send because of a safety number change
/// UI observing call state may choose to alert the user (e.g. presenting a SafetyNumberConfirmationSheet)
func callMessageSendFailedUntrustedIdentity(_ call: GroupThreadCall)
}
extension GroupThreadCallObserver {
func groupCallLocalDeviceStateChanged(_ call: GroupThreadCall) {}
func groupCallRemoteDeviceStatesChanged(_ call: GroupThreadCall) {}
func groupCallPeekChanged(_ call: GroupThreadCall) {}
func groupCallRequestMembershipProof(_ call: GroupThreadCall) {}
func groupCallRequestGroupMembers(_ call: GroupThreadCall) {}
func groupCallEnded(_ call: GroupThreadCall, reason: GroupCallEndReason) {}
func groupCallReceivedReactions(_ call: GroupThreadCall, reactions: [SignalRingRTC.Reaction]) {}
func groupCallReceivedRaisedHands(_ call: GroupThreadCall, raisedHands: [UInt32]) {}
func callMessageSendFailedUntrustedIdentity(_ call: GroupThreadCall) {}
}
class GroupThreadCall {
let commonState: CommonCallState
let ringRtcCall: SignalRingRTC.GroupCall
let groupThread: TSGroupThread
let videoCaptureController: VideoCaptureController
private(set) var raisedHands: [RemoteDeviceState] = []
init(
ringRtcCall: SignalRingRTC.GroupCall,
groupThread: TSGroupThread,
videoCaptureController: VideoCaptureController
) {
self.commonState = CommonCallState(
audioActivity: AudioActivity(
audioDescription: "[SignalCall] with group \(groupThread.groupModel.groupId)",
behavior: .call
)
)
self.ringRestrictions = []
self.ringRtcCall = ringRtcCall
self.groupThread = groupThread
self.videoCaptureController = videoCaptureController
self.ringRtcCall.delegate = self
if groupThread.groupModel.groupMembers.count > RemoteConfig.maxGroupCallRingSize {
self.ringRestrictions.insert(.groupTooLarge)
}
// Track the callInProgress restriction regardless; we use that for
// purposes other than rings.
let hasActiveCallMessage = NSObject.databaseStorage.read { transaction -> Bool in
!GroupCallInteractionFinder().unendedCallsForGroupThread(groupThread, transaction: transaction).isEmpty
}
if hasActiveCallMessage {
// This info may be out of date, but the first peek will update it.
self.ringRestrictions.insert(.callInProgress)
}
// Watch group membership changes. The object is the group thread ID, which
// is a string. NotificationCenter dispatches by object identity rather
// than equality, so we watch all changes and filter later.
NotificationCenter.default.addObserver(
self,
selector: #selector(groupMembershipDidChange),
name: TSGroupThread.membershipDidChange,
object: nil
)
}
var joinState: JoinState {
return self.ringRtcCall.localDeviceState.joinState
}
var hasTerminated: Bool {
switch groupCallRingState {
case .incomingRingCancelled:
return true
case .doNotRing, .shouldRing, .ringing, .ringingEnded, .incomingRing:
return false
}
}
// MARK: - Observers
private var observers: WeakArray<any GroupThreadCallObserver> = []
func addObserverAndSyncState(_ observer: any GroupThreadCallObserver) {
AssertIsOnMainThread()
observers.append(observer)
// Synchronize observer with current call state
observer.groupCallLocalDeviceStateChanged(self)
observer.groupCallRemoteDeviceStatesChanged(self)
}
func removeObserver(_ observer: any GroupThreadCallObserver) {
observers.removeAll(where: { $0 === observer })
}
func publishSendFailureUntrustedParticipantIdentity() {
observers.elements.forEach { $0.callMessageSendFailedUntrustedIdentity(self) }
}
// MARK: - Ringing
struct RingRestrictions: OptionSet {
var rawValue: UInt8
/// The user cannot ring because there is already a call in progress.
static let callInProgress = Self(rawValue: 1 << 1)
/// This group is too large to allow ringing.
static let groupTooLarge = Self(rawValue: 1 << 2)
}
var ringRestrictions: RingRestrictions {
didSet {
AssertIsOnMainThread()
if ringRestrictions != oldValue, joinState == .notJoined {
// Use a fake local state change to refresh the call controls.
//
// If we ever introduce ringing restrictions for 1:1 calls, a similar
// affordance will be needed to refresh the call controls.
self.groupCall(onLocalDeviceStateChanged: ringRtcCall)
}
}
}
@objc
private func groupMembershipDidChange(_ notification: Notification) {
// NotificationCenter dispatches by object identity rather than equality,
// so we filter based on the thread ID here.
guard groupThread.uniqueId == notification.object as? String else {
return
}
NSObject.databaseStorage.read(block: groupThread.anyReload(transaction:))
let groupModel = groupThread.groupModel
let isGroupTooLarge = groupModel.groupMembers.count > RemoteConfig.maxGroupCallRingSize
ringRestrictions.update(.groupTooLarge, present: isGroupTooLarge)
}
enum GroupCallRingState {
case doNotRing
case shouldRing
case ringing
case ringingEnded
case incomingRing(caller: SignalServiceAddress, ringId: Int64)
case incomingRingCancelled
var isIncomingRing: Bool {
switch self {
case .incomingRing, .incomingRingCancelled:
return true
default:
return false
}
}
}
var groupCallRingState: GroupCallRingState = .shouldRing {
didSet {
AssertIsOnMainThread()
}
}
}
// MARK: - GroupCallDelegate
extension GroupThreadCall: GroupCallDelegate {
public func groupCall(onLocalDeviceStateChanged groupCall: SignalRingRTC.GroupCall) {
if groupCall.localDeviceState.joinState == .joined, commonState.setConnectedDateIfNeeded() {
if groupCallRingState.isIncomingRing {
groupCallRingState = .ringingEnded
}
// make sure we don't terminate audio session during call
NSObject.audioSession.isRTCAudioEnabled = true
owsAssertDebug(NSObject.audioSession.startAudioActivity(commonState.audioActivity))
}
observers.elements.forEach { $0.groupCallLocalDeviceStateChanged(self) }
}
public func groupCall(onRemoteDeviceStatesChanged groupCall: SignalRingRTC.GroupCall) {
observers.elements.forEach { $0.groupCallRemoteDeviceStatesChanged(self) }
// Change this after notifying observers so that they can see when the ring has concluded.
if case .ringing = groupCallRingState, !groupCall.remoteDeviceStates.isEmpty {
groupCallRingState = .ringingEnded
// Treat the end of ringing as a "local state change" for listeners that normally ignore remote changes.
self.groupCall(onLocalDeviceStateChanged: groupCall)
}
}
public func groupCall(onAudioLevels groupCall: SignalRingRTC.GroupCall) {
// TODO: Implement audio level handling for group calls.
}
public func groupCall(onLowBandwidthForVideo groupCall: SignalRingRTC.GroupCall, recovered: Bool) {
// TODO: Implement handling of the "low outgoing bandwidth for video" notification.
}
public func groupCall(onReactions groupCall: SignalRingRTC.GroupCall, reactions: [SignalRingRTC.Reaction]) {
observers.elements.forEach {
$0.groupCallReceivedReactions(self, reactions: reactions)
}
}
public func groupCall(onRaisedHands groupCall: SignalRingRTC.GroupCall, raisedHands: [UInt32]) {
guard
FeatureFlags.callRaiseHandReceiveSupport,
FeatureFlags.useCallMemberComposableViewsForRemoteUsersInGroupCalls
else { return }
self.raisedHands = raisedHands.compactMap { groupCall.remoteDeviceStates[$0] }
observers.elements.forEach {
$0.groupCallReceivedRaisedHands(self, raisedHands: raisedHands)
}
}
public func groupCall(onPeekChanged groupCall: SignalRingRTC.GroupCall) {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let localAci = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
owsFailDebug("Peek changed for a group call, but we're not registered?")
return
}
if let peekInfo = groupCall.peekInfo {
// Note that we track this regardless of whether ringing is available.
// There are other places that use this.
let minDevicesToConsiderCallInProgress: UInt32 = {
if peekInfo.joinedMembers.contains(localAci.rawUUID) {
// If we're joined, require us + someone else.
return 2
} else {
// Otherwise, anyone else in the call counts.
return 1
}
}()
ringRestrictions.update(
.callInProgress,
present: peekInfo.deviceCountExcludingPendingDevices >= minDevicesToConsiderCallInProgress
)
}
observers.elements.forEach { $0.groupCallPeekChanged(self) }
}
public func groupCall(requestMembershipProof groupCall: SignalRingRTC.GroupCall) {
observers.elements.forEach { $0.groupCallRequestMembershipProof(self) }
}
public func groupCall(requestGroupMembers groupCall: SignalRingRTC.GroupCall) {
observers.elements.forEach { $0.groupCallRequestGroupMembers(self) }
}
public func groupCall(onEnded groupCall: SignalRingRTC.GroupCall, reason: GroupCallEndReason) {
observers.elements.forEach { $0.groupCallEnded(self, reason: reason) }
}
}
// MARK: - GroupCall
extension GroupCall {
var isFull: Bool {
guard let peekInfo = peekInfo, let maxDevices = peekInfo.maxDevices else {
return false
}
return peekInfo.deviceCountExcludingPendingDevices >= maxDevices
}
var maxDevices: UInt32? {
guard let peekInfo = peekInfo, let maxDevices = peekInfo.maxDevices else {
return nil
}
return maxDevices
}
}