Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Calls/GroupThreadCall.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI

protocol GroupThreadCallDelegate: AnyObject {
    @MainActor
    func groupThreadCallRequestMembershipProof(_ call: GroupThreadCall)
    @MainActor
    func groupThreadCallRequestGroupMembers(_ call: GroupThreadCall)
}

final class GroupThreadCall: Signal.GroupCall {
    private weak var delegate: (any GroupThreadCallDelegate)?

    let groupId: GroupIdentifier
    let threadUniqueId: String
    var membershipDidChangeObserver: (any NSObjectProtocol)!

    init?(
        delegate: any GroupThreadCallDelegate,
        ringRtcCall: SignalRingRTC.GroupCall,
        groupId: GroupIdentifier,
        videoCaptureController: VideoCaptureController,
    ) {
        self.delegate = delegate
        self.groupId = groupId

        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let groupThread = databaseStorage.read { tx in
            return TSGroupThread.fetch(forGroupId: groupId, tx: tx)
        }
        guard let groupThread else {
            owsFailDebug("Missing thread for active call.")
            return nil
        }

        self.threadUniqueId = groupThread.uniqueId

        super.init(
            audioDescription: "[SignalCall] with group \(groupId)",
            ringRtcCall: ringRtcCall,
            videoCaptureController: videoCaptureController,
        )

        if groupThread.groupModel.groupMembers.count > RemoteConfig.current.maxGroupCallRingSize {
            self.ringRestrictions.insert(.groupTooLarge)
        }

        // Track the callInProgress restriction regardless; we use that for
        // purposes other than rings.
        let hasActiveCallMessage = SSKEnvironment.shared.databaseStorageRef.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.
        self.membershipDidChangeObserver = NotificationCenter.default.addObserver(forName: TSGroupThread.membershipDidChange, object: nil, queue: .main) { [weak self] notification in
            guard let self else { return }
            MainActor.assumeIsolated {
                self.groupMembershipDidChange(notification)
            }
        }
    }

    deinit {
        NotificationCenter.default.removeObserver(membershipDidChangeObserver!)
    }

    var hasTerminated: Bool {
        switch groupCallRingState {
        case .incomingRingCancelled:
            return true
        case .doNotRing, .shouldRing, .ringing, .ringingEnded, .incomingRing:
            return false
        }
    }

    // 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)
    }

    @MainActor
    var ringRestrictions: RingRestrictions = [] {
        didSet {
            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)
            }
        }
    }

    @MainActor
    private func groupMembershipDidChange(_ notification: Notification) {
        // NotificationCenter dispatches by object identity rather than equality,
        // so we filter based on the thread ID here.
        guard threadUniqueId == notification.object as? String else {
            return
        }
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let groupThread = databaseStorage.read { tx in
            return TSGroupThread.fetch(forGroupId: groupId, tx: tx)
        }
        guard let groupThread else {
            owsFailDebug("Missing group thread for active call.")
            return
        }
        let groupModel = groupThread.groupModel
        let isGroupTooLarge = groupModel.groupMembers.count > RemoteConfig.current.maxGroupCallRingSize
        ringRestrictions.update(.groupTooLarge, present: isGroupTooLarge)
    }

    enum GroupCallRingState {
        case doNotRing
        case shouldRing
        case ringing
        case ringingEnded
        case incomingRing(caller: Aci, 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

    override func groupCall(onLocalDeviceStateChanged groupCall: SignalRingRTC.GroupCall) {
        if groupCallRingState.isIncomingRing, groupCall.localDeviceState.joinState == .joined {
            groupCallRingState = .ringingEnded
        }

        super.groupCall(onLocalDeviceStateChanged: groupCall)
    }

    override func groupCall(onRemoteDeviceStatesChanged groupCall: SignalRingRTC.GroupCall) {
        super.groupCall(onRemoteDeviceStatesChanged: groupCall)

        // 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)
        }
    }

    override 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,
            )
        }

        super.groupCall(onPeekChanged: groupCall)
    }

    override func groupCall(requestMembershipProof groupCall: SignalRingRTC.GroupCall) {
        super.groupCall(requestMembershipProof: groupCall)

        delegate?.groupThreadCallRequestMembershipProof(self)
    }

    override func groupCall(requestGroupMembers groupCall: SignalRingRTC.GroupCall) {
        super.groupCall(requestGroupMembers: groupCall)

        delegate?.groupThreadCallRequestGroupMembers(self)
    }
}

// MARK: - GroupCall

extension SignalRingRTC.GroupCall {
    var isFull: Bool {
        guard let peekInfo, let maxDevices = peekInfo.maxDevices else {
            return false
        }
        return peekInfo.deviceCountIncludingPendingDevices >= maxDevices
    }

    var maxDevices: UInt32? {
        guard let peekInfo, let maxDevices = peekInfo.maxDevices else {
            return nil
        }
        return maxDevices
    }
}