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

import Foundation
public import LibSignalClient

// MARK: - GroupMemberState

private enum GroupMemberState: Equatable, Codable, CustomStringConvertible {
    case fullMember(
        role: TSGroupMemberRole,
        didJoinFromInviteLink: Bool,
        didJoinFromAcceptedJoinRequest: Bool,
    )
    case invited(role: TSGroupMemberRole, addedByAci: Aci)
    case requesting

    var role: TSGroupMemberRole {
        switch self {
        case .fullMember(let role, _, _):
            return role
        case .invited(let role, _):
            return role
        case .requesting:
            return .`normal`
        }
    }

    var isAdministrator: Bool {
        role == .administrator
    }

    var isFullMember: Bool {
        switch self {
        case .fullMember: return true
        default: return false
        }
    }

    var isInvited: Bool {
        switch self {
        case .invited: return true
        default: return false
        }
    }

    var isRequesting: Bool {
        switch self {
        case .requesting: return true
        default: return false
        }
    }

    // MARK: -

    private enum TypeKey: UInt, Codable {
        case fullMember = 0
        case invited = 1
        case requesting = 2
    }

    private enum CodingKeys: String, CodingKey {
        case typeKey
        case role
        case addedByAci = "addedByUuid"
        case didJoinFromInviteLink
        case didJoinFromAcceptedJoinRequest
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        let typeKey = try container.decode(TypeKey.self, forKey: .typeKey)
        switch typeKey {
        case .fullMember:
            let role = try container.decode(TSGroupMemberRole.self, forKey: .role)
            let didJoinFromInviteLink = try container.decodeIfPresent(Bool.self, forKey: .didJoinFromInviteLink) ?? false
            let didJoinFromAcceptedJoinRequest = try container.decodeIfPresent(
                Bool.self,
                forKey: .didJoinFromAcceptedJoinRequest,
            ) ?? false
            self = .fullMember(
                role: role,
                didJoinFromInviteLink: didJoinFromInviteLink,
                didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest,
            )
        case .invited:
            let role = try container.decode(TSGroupMemberRole.self, forKey: .role)
            let addedByAci = try container.decode(UUID.self, forKey: .addedByAci)
            self = .invited(role: role, addedByAci: Aci(fromUUID: addedByAci))
        case .requesting:
            self = .requesting
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        switch self {
        case .fullMember(let role, let didJoinFromInviteLink, let didJoinFromAcceptedJoinRequest):
            try container.encode(TypeKey.fullMember, forKey: .typeKey)
            try container.encode(role, forKey: .role)
            try container.encode(didJoinFromInviteLink, forKey: .didJoinFromInviteLink)
            try container.encode(
                didJoinFromAcceptedJoinRequest,
                forKey: .didJoinFromAcceptedJoinRequest,
            )
        case .invited(let role, let addedByAci):
            try container.encode(TypeKey.invited, forKey: .typeKey)
            try container.encode(role, forKey: .role)
            try container.encode(addedByAci.rawUUID, forKey: .addedByAci)
        case .requesting:
            try container.encode(TypeKey.requesting, forKey: .typeKey)
        }
    }

    // MARK: -

    var description: String {
        switch self {
        case .fullMember: return ".fullMember"
        case .invited: return ".invited"
        case .requesting: return ".requesting"
        }
    }
}

// MARK: -

@objc
public class GroupMembership: NSObject, NSSecureCoding {

    // MARK: Types

    public typealias BannedAtTimestampMillis = UInt64
    public typealias BannedMembersMap = [Aci: BannedAtTimestampMillis]

    fileprivate typealias MemberStateMap = [SignalServiceAddress: GroupMemberState]
    fileprivate typealias InvalidInviteMap = [Data: InvalidInviteModel]
    fileprivate typealias MemberLabelsMap = [Aci: MemberLabel]

    private typealias LegacyMemberStateMap = [SignalServiceAddress: LegacyMemberState]

    // MARK: Init

    fileprivate var memberStates: MemberStateMap
    public fileprivate(set) var bannedMembers: BannedMembersMap
    private var invalidInviteMap: InvalidInviteMap
    private var memberLabels: MemberLabelsMap

    public var invalidInviteUserIds: [Data] {
        return Array(invalidInviteMap.keys)
    }

    @objc
    override public init() {
        self.memberStates = [:]
        self.bannedMembers = [:]
        self.invalidInviteMap = [:]
        self.memberLabels = [:]

        super.init()
    }

    public static var supportsSecureCoding: Bool { true }

    @objc
    public required init?(coder: NSCoder) {
        self.invalidInviteMap = coder.decodeDictionary(
            withKeyClass: NSData.self,
            objectClass: InvalidInviteModel.self,
            forKey: Self.invalidInviteMapKey,
        ) as [Data: InvalidInviteModel]? ?? [:]

        if let memberStatesData = coder.decodeObject(of: NSData.self, forKey: Self.memberStatesKey) as Data? {
            let decoder = JSONDecoder()
            do {
                self.memberStates = try decoder.decode(MemberStateMap.self, from: memberStatesData)
            } catch {
                owsFailDebug("Could not decode member states: \(error)")
                return nil
            }
        } else if
            let legacyMemberStateMap = coder.decodeDictionary(
                withKeyClass: SignalServiceAddress.self,
                objectClass: LegacyMemberState.self,
                forKey: Self.legacyMemberStatesKey,
            ) as LegacyMemberStateMap?
        {
            self.memberStates = Self.convertLegacyMemberStateMap(legacyMemberStateMap)
        } else {
            owsFailDebug("Could not decode legacy member states.")
            return nil
        }

        if
            let bannedMembers = coder.decodeDictionary(
                withKeyClass: NSUUID.self,
                objectClass: NSNumber.self,
                forKey: Self.bannedMembersKey,
            ) as [UUID: NSNumber]? as? [UUID: UInt64]
        {
            self.bannedMembers = bannedMembers.mapKeys(injectiveTransform: { Aci(fromUUID: $0) })
        } else {
            // TODO: (Group Abuse) we should debug assert here eventually.
            // However, while clients are learning about banned members this is
            // a normal path to hit.
            self.bannedMembers = [:]
        }

        if let memberLabelsData = coder.decodeObject(of: NSData.self, forKey: Self.memberLabelsMapKey) as Data? {
            let decoder = JSONDecoder()
            do {
                let memberLabelServiceIdBinaryMap = try decoder.decode(Dictionary<UUID, MemberLabel>.self, from: memberLabelsData)
                self.memberLabels = memberLabelServiceIdBinaryMap.mapKeys(injectiveTransform: { Aci(fromUUID: $0) })
            } catch {
                owsFailDebug("Could not decode member labels: \(error)")
                return nil
            }
        } else {
            self.memberLabels = [:]
        }

        super.init()
    }

    private static var memberStatesKey: String { "memberStates" }
    private static var legacyMemberStatesKey: String { "memberStateMap" }
    private static var bannedMembersKey: String { "bannedMembers" }
    private static var invalidInviteMapKey: String { "invalidInviteMap" }
    private static var memberLabelsMapKey: String { "memberLabelsMapV3" }

    public func encode(with aCoder: NSCoder) {
        let encoder = JSONEncoder()
        do {
            let memberStatesData = try encoder.encode(self.memberStates)
            aCoder.encode(memberStatesData, forKey: Self.memberStatesKey)
        } catch {
            owsFailDebug("Error: \(error)")
        }

        aCoder.encode(bannedMembers.mapKeys(injectiveTransform: { $0.rawUUID }), forKey: Self.bannedMembersKey)
        aCoder.encode(invalidInviteMap, forKey: Self.invalidInviteMapKey)

        do {
            let memberLabelsData = try encoder.encode(self.memberLabels.mapKeys(injectiveTransform: { $0.rawUUID }))
            aCoder.encode(memberLabelsData, forKey: Self.memberLabelsMapKey)
        } catch {
            owsFailDebug("Error: \(error)")
        }
    }

    fileprivate init(
        memberStates: MemberStateMap,
        bannedMembers: BannedMembersMap,
        invalidInviteMap: InvalidInviteMap,
        memberLabels: MemberLabelsMap,
    ) {
        self.memberStates = memberStates
        self.bannedMembers = bannedMembers
        self.invalidInviteMap = invalidInviteMap
        self.memberLabels = memberLabels

        super.init()
    }

    @objc
    init(v1Members: [SignalServiceAddress]) {
        var builder = Builder()
        builder.addFullMembers(Set(v1Members), role: .normal)
        self.memberStates = builder.memberStates
        self.bannedMembers = [:]
        self.invalidInviteMap = [:]
        self.memberLabels = [:]

        super.init()
    }

    public func showInfoMessageForChangeComparedTo(
        to other: GroupMembership,
    ) -> Bool {
        guard
            Self.memberStates(
                self.memberStates,
                areEqualTo: other.memberStates,
            )
        else {
            return true
        }

        guard self.bannedMembers == other.bannedMembers else {
            return true
        }

        let invalidlyInvitedUserIdsSet = Set(invalidInviteUserIds)
        let otherInvalidlyInvitedUserIdsSet = Set(other.invalidInviteUserIds)
        return invalidlyInvitedUserIdsSet != otherInvalidlyInvitedUserIdsSet
    }

#if TESTABLE_BUILD
    /// Construction for tests is functionally equivalent to construction of a
    /// group membership for a legacy, V1 group model.
    public convenience init(membersForTest: [SignalServiceAddress]) {
        self.init(v1Members: membersForTest)
    }
#endif

    // MARK: - Equality

    @objc
    override public func isEqual(_ object: Any!) -> Bool {
        guard let other = object as? GroupMembership else {
            return false
        }

        guard
            Self.memberStates(
                self.memberStates,
                areEqualTo: other.memberStates,
            )
        else {
            return false
        }

        guard self.bannedMembers == other.bannedMembers else {
            return false
        }

        guard self.memberLabels == other.memberLabels else {
            return false
        }

        let invalidlyInvitedUserIdsSet = Set(invalidInviteUserIds)
        let otherInvalidlyInvitedUserIdsSet = Set(other.invalidInviteUserIds)
        return invalidlyInvitedUserIdsSet == otherInvalidlyInvitedUserIdsSet
    }

    /// When comparing member states, ignore the ``didJoinFromInviteLink`` and
    /// ``didJoinFromAcceptedJoinRequest`` fields.
    /// These fields are not stored as part of memberships in group snapshots from
    /// the service, and are only computed when a member joins a group and we add
    /// them locally. If our local membership differs from a group snapshot's
    /// only in these fields, we want to consider them equal to avoid clobbering our local state.
    private static func memberStates(
        _ memberStates: MemberStateMap,
        areEqualTo otherMemberStates: MemberStateMap,
    ) -> Bool {

        func hardcodeDidJoinViaInviteLink(for groupMemberState: GroupMemberState) -> GroupMemberState {
            switch groupMemberState {
            case .fullMember(let role, _, _):
                return .fullMember(
                    role: role,
                    didJoinFromInviteLink: false,
                    didJoinFromAcceptedJoinRequest: false,
                )
            default:
                return groupMemberState
            }
        }

        guard memberStates.count == otherMemberStates.count else {
            return false
        }

        return memberStates.allSatisfy { key, value -> Bool in
            guard let otherValue = otherMemberStates[key] else { return false }
            return hardcodeDidJoinViaInviteLink(for: value) == hardcodeDidJoinViaInviteLink(for: otherValue)
        }
    }

    // MARK: -

    private static func convertLegacyMemberStateMap(_ legacyMemberStateMap: LegacyMemberStateMap) -> MemberStateMap {
        var result = MemberStateMap()
        for (address, legacyMemberState) in legacyMemberStateMap {
            let memberState: GroupMemberState
            if legacyMemberState.isPending {
                if let addedByUuid = legacyMemberState.addedByUuid {
                    memberState = .invited(role: legacyMemberState.role, addedByAci: Aci(fromUUID: addedByUuid))
                } else {
                    owsFailDebug("Missing addedByUuid.")
                    continue
                }
            } else {
                memberState = .fullMember(
                    role: legacyMemberState.role,
                    didJoinFromInviteLink: false,
                    didJoinFromAcceptedJoinRequest: false,
                )
            }
            result[address] = memberState
        }
        return result
    }

    // MARK: -

    public static var empty: GroupMembership {
        return Builder().build()
    }

    public var asBuilder: Builder {
        return Builder(
            memberStates: memberStates,
            bannedMembers: bannedMembers,
            invalidInviteMap: invalidInviteMap,
            memberLabels: memberLabels,
        )
    }

    override public var debugDescription: String {
        var result = "[\n"
        for address in allMembersOfAnyKind.sorted(by: { ($0.serviceId?.serviceIdString ?? "") < ($1.serviceId?.serviceIdString ?? "") }) {
            guard let memberState = memberStates[address] else {
                owsFailDebug("Missing memberState.")
                continue
            }
            result += "\(address), memberType: \(memberState)\n"
        }
        for (aci, bannedAtTimestamp) in bannedMembers {
            result += "Banned: \(aci), at \(bannedAtTimestamp)\n"
        }
        result += "]"
        return result
    }

    // MARK: -

    public var allMembersOfAnyKind: Set<SignalServiceAddress> {
        return Set(memberStates.keys)
    }

    public var allMembersOfAnyKindServiceIds: Set<ServiceId> {
        return Set(memberStates.keys.lazy.compactMap { $0.serviceId })
    }

    public func isMemberOfAnyKind(_ address: SignalServiceAddress) -> Bool {
        return memberStates[address] != nil
    }

    public func isMemberOfAnyKind(_ serviceId: ServiceId) -> Bool {
        return isMemberOfAnyKind(SignalServiceAddress(serviceId))
    }

    // MARK: -

    public func role(for serviceId: ServiceId) -> TSGroupMemberRole? {
        return role(for: SignalServiceAddress(serviceId))
    }

    public func role(for address: SignalServiceAddress) -> TSGroupMemberRole? {
        guard let memberState = memberStates[address] else {
            return nil
        }
        return memberState.role
    }

    // MARK: -

    public var fullMemberAdministrators: Set<SignalServiceAddress> {
        return Set(memberStates.lazy.filter { $0.value.isAdministrator && $0.value.isFullMember }.map { $0.key })
    }

    public var fullMembers: Set<SignalServiceAddress> {
        return Set(memberStates.lazy.filter { $0.value.isFullMember }.map { $0.key })
    }

    public func isFullMemberAndAdministrator(_ address: SignalServiceAddress) -> Bool {
        guard let memberState = memberStates[address] else {
            return false
        }
        return memberState.isAdministrator && memberState.isFullMember
    }

    public func isFullMemberAndAdministrator(_ serviceId: ServiceId) -> Bool {
        return isFullMemberAndAdministrator(SignalServiceAddress(serviceId))
    }

    @objc
    public func isFullMember(_ address: SignalServiceAddress) -> Bool {
        guard let memberState = memberStates[address] else {
            return false
        }
        return memberState.isFullMember
    }

    public func isFullMember(_ serviceId: ServiceId) -> Bool {
        return isFullMember(SignalServiceAddress(serviceId))
    }

    /// This method should only be called for full members.
    public func didJoinFromInviteLink(forFullMember address: SignalServiceAddress) -> Bool {
        guard let memberState = memberStates[address] else {
            owsFailDebug("Missing member: \(address)")
            return false
        }
        switch memberState {
        case .fullMember(_, let didJoinFromInviteLink, _):
            return didJoinFromInviteLink
        default:
            owsFailDebug("Not a full member.")
            return false
        }
    }

    /// this method should only be called for full members.
    public func didJoinFromAcceptedJoinRequest(forFullMember address: SignalServiceAddress) -> Bool {
        guard let memberState = memberStates[address] else {
            owsFailDebug("Missing member: \(address)")
            return false
        }
        switch memberState {
        case .fullMember(_, _, let didJoinFromAcceptedJoinRequest):
            return didJoinFromAcceptedJoinRequest
        default:
            owsFailDebug("Not a full member.")
            return false
        }
    }

    // MARK: -

    public var invitedMembers: Set<SignalServiceAddress> {
        return Set(memberStates.lazy.filter { $0.value.isInvited }.map { $0.key })
    }

    public func isInvitedMember(_ address: SignalServiceAddress) -> Bool {
        guard let memberState = memberStates[address] else {
            return false
        }
        return memberState.isInvited
    }

    public func isInvitedMember(_ serviceId: ServiceId) -> Bool {
        return isInvitedMember(SignalServiceAddress(serviceId))
    }

    /// This method should only be called on invited members.
    public func addedByAci(forInvitedMember address: SignalServiceAddress) -> Aci? {
        guard let memberState = memberStates[address] else {
            return nil
        }
        switch memberState {
        case .invited(_, let addedByAci):
            return addedByAci
        default:
            owsFailDebug("Not a pending profile key member.")
            return nil
        }
    }

    public func addedByAci(forInvitedMember serviceId: ServiceId) -> Aci? {
        return addedByAci(forInvitedMember: SignalServiceAddress(serviceId))
    }

    // MARK: -

    public var requestingMembers: Set<SignalServiceAddress> {
        return Set(memberStates.lazy.filter { $0.value.isRequesting }.map { $0.key })
    }

    public func isRequestingMember(_ address: SignalServiceAddress) -> Bool {
        guard let memberState = memberStates[address] else {
            return false
        }
        return memberState.isRequesting
    }

    public func isRequestingMember(_ serviceId: ServiceId) -> Bool {
        return isRequestingMember(SignalServiceAddress(serviceId))
    }

    // MARK: -

    public func isBannedMember(_ aci: Aci) -> Bool {
        return bannedMembers[aci] != nil
    }

    public func hasInvalidInvite(forUserId userId: Data) -> Bool {
        return invalidInviteMap[userId] != nil
    }

    // MARK: -

    public func canLocalUserLeaveGroupWithoutChoosingNewAdmin(localAci: Aci) -> Bool {
        let fullMembers = Set(self.fullMembers.compactMap { $0.serviceId as? Aci })
        let fullMemberAdmins = Set(self.fullMemberAdministrators.compactMap { $0.serviceId as? Aci })
        return Self.canLocalUserLeaveGroupWithoutChoosingNewAdmin(
            localAci: localAci,
            fullMembers: fullMembers,
            admins: fullMemberAdmins,
        )
    }

    static func canLocalUserLeaveGroupWithoutChoosingNewAdmin(
        localAci: Aci,
        fullMembers: Set<Aci>,
        admins: Set<Aci>,
    ) -> Bool {
        // If there's already another admin or we're the only member, we can leave
        // without selecting a new admin.
        return Set([localAci]) != admins || Set([localAci]) == fullMembers
    }

    // MARK: -

    /// Is this user's profile key exposed to the group?
    public func hasProfileKeyInGroup(serviceId: ServiceId) -> Bool {
        guard let memberState = memberStates[SignalServiceAddress(serviceId)] else {
            return false
        }

        switch memberState {
        case .fullMember, .requesting:
            return true
        case .invited:
            return false
        }
    }

    /// Can this user view the profile keys in the group?
    public func canViewProfileKeys(serviceId: ServiceId) -> Bool {
        guard let memberState = memberStates[SignalServiceAddress(serviceId)] else {
            return false
        }

        switch memberState {
        case .fullMember, .invited:
            return true
        case .requesting:
            return false
        }
    }

    // MARK: -

    public enum AddableResult {
        case alreadyInGroup
        case addableWithProfileKeyCredential
        case addableOrInvitable
    }

    public func canTryToAddToGroup(serviceId: ServiceId) -> AddableResult {
        if self.isFullMember(serviceId) {
            return .alreadyInGroup
        }
        if self.isRequestingMember(serviceId) {
            return .addableOrInvitable
        }
        if self.isInvitedMember(serviceId) {
            return .addableWithProfileKeyCredential
        }
        return .addableOrInvitable
    }

    public static func canTryToAddWithProfileKeyCredential(
        serviceId: ServiceId,
        groupsV2: any GroupsV2 = SSKEnvironment.shared.groupsV2Ref,
        profileManager: any ProfileManager = SSKEnvironment.shared.profileManagerRef,
        tsAccountManager: any TSAccountManager = DependenciesBridge.shared.tsAccountManager,
        udManager: any OWSUDManager = SSKEnvironment.shared.udManagerRef,
        tx: DBReadTransaction,
    ) -> Bool {
        // We can add invited members if we have...
        if let aci = serviceId as? Aci, groupsV2.hasProfileKeyCredential(for: aci, transaction: tx) {
            return true
        }
        // ...or can get a credential for them.
        return ProfileFetcherJob.canTryToFetchCredential(
            serviceId: serviceId,
            localIdentifiers: tsAccountManager.localIdentifiers(tx: tx)!,
            profileManager: profileManager,
            udManager: udManager,
            tx: tx,
        )
    }

    // MARK:

    public func memberLabel(for aci: Aci) -> MemberLabel? {
        return memberLabels[aci]
    }

    // MARK: - Builder

    public struct Builder {
        fileprivate var memberStates = MemberStateMap()
        private var bannedMembers = BannedMembersMap()
        private var invalidInviteMap = InvalidInviteMap()
        private var memberLabels = MemberLabelsMap()

        public init() {}

        fileprivate init(
            memberStates: MemberStateMap,
            bannedMembers: BannedMembersMap,
            invalidInviteMap: InvalidInviteMap,
            memberLabels: MemberLabelsMap,
        ) {
            self.memberStates = memberStates
            self.bannedMembers = bannedMembers
            self.invalidInviteMap = invalidInviteMap
            self.memberLabels = memberLabels
        }

        // MARK: Member states

        public mutating func remove(_ serviceId: ServiceId) {
            remove(SignalServiceAddress(serviceId))
        }

        public mutating func remove(_ address: SignalServiceAddress) {
            remove([address])
        }

        public mutating func remove(_ addresses: Set<SignalServiceAddress>) {
            for address in addresses {
                memberStates.removeValue(forKey: address)
            }
        }

        public mutating func addFullMember(
            _ aci: Aci,
            role: TSGroupMemberRole,
            didJoinFromInviteLink: Bool = false,
            didJoinFromAcceptedJoinRequest: Bool = false,
        ) {
            addFullMember(
                SignalServiceAddress(aci),
                role: role,
                didJoinFromInviteLink: didJoinFromInviteLink,
                didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest,
            )
        }

        public mutating func addFullMember(
            _ address: SignalServiceAddress,
            role: TSGroupMemberRole,
            didJoinFromInviteLink: Bool = false,
            didJoinFromAcceptedJoinRequest: Bool = false,
        ) {
            addFullMembers(
                [address],
                role: role,
                didJoinFromInviteLink: didJoinFromInviteLink,
                didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest,
            )
        }

        public mutating func addFullMembers(
            _ addresses: Set<SignalServiceAddress>,
            role: TSGroupMemberRole,
            didJoinFromInviteLink: Bool = false,
            didJoinFromAcceptedJoinRequest: Bool = false,
        ) {
            // Dupe is not necessarily an error; you might know of the UUID
            // mapping for a user that another group member doesn't know about.
            addMembers(
                addresses,
                withState: .fullMember(
                    role: role,
                    didJoinFromInviteLink: didJoinFromInviteLink,
                    didJoinFromAcceptedJoinRequest: didJoinFromAcceptedJoinRequest,
                ),
                failOnDupe: false,
            )
        }

        public mutating func addInvitedMember(_ serviceId: ServiceId, role: TSGroupMemberRole, addedByAci: Aci) {
            addInvitedMember(SignalServiceAddress(serviceId), role: role, addedByAci: addedByAci)
        }

        public mutating func addInvitedMember(
            _ address: SignalServiceAddress,
            role: TSGroupMemberRole,
            addedByAci: Aci,
        ) {
            addInvitedMembers([address], role: role, addedByAci: addedByAci)
        }

        public mutating func addInvitedMembers(
            _ addresses: Set<SignalServiceAddress>,
            role: TSGroupMemberRole,
            addedByAci: Aci,
        ) {
            addMembers(addresses, withState: .invited(role: role, addedByAci: addedByAci))
        }

        public mutating func addRequestingMember(_ aci: Aci) {
            addRequestingMember(SignalServiceAddress(aci))
        }

        public mutating func addRequestingMember(_ address: SignalServiceAddress) {
            addRequestingMembers([address])
        }

        public mutating func addRequestingMembers(_ addresses: Set<SignalServiceAddress>) {
            addMembers(addresses, withState: .requesting)
        }

        private mutating func addMembers(
            _ addresses: Set<SignalServiceAddress>,
            withState memberState: GroupMemberState,
            failOnDupe: Bool = true,
        ) {
            for address in addresses {
                guard memberStates[address] == nil else {
                    let errorMessage = "Duplicate address."
                    if failOnDupe {
                        owsFailDebug(errorMessage)
                    } else {
                        Logger.warn(errorMessage)
                    }
                    continue
                }

                memberStates[address] = memberState
            }
        }

        public func hasMemberOfAnyKind(_ address: SignalServiceAddress) -> Bool {
            nil != memberStates[address]
        }

        // MARK: Banned members

        public mutating func addBannedMember(_ aci: Aci, bannedAtTimestamp: BannedAtTimestampMillis) {
            guard bannedMembers[aci] == nil else {
                owsFailDebug("Duplicate banned member!")
                return
            }

            bannedMembers[aci] = bannedAtTimestamp
        }

        public mutating func removeBannedMember(_ aci: Aci) {
            guard bannedMembers[aci] != nil else {
                owsFailDebug("Removing not-currently-banned member!")
                return
            }

            bannedMembers.removeValue(forKey: aci)
        }

        // MARK: Invalid invites

        public mutating func addInvalidInvite(userId: Data, addedByUserId: Data) {
            invalidInviteMap[userId] = InvalidInviteModel(userId: userId, addedByUserId: addedByUserId)
        }

        public mutating func removeInvalidInvite(userId: Data) {
            invalidInviteMap.removeValue(forKey: userId)
        }

        public func hasInvalidInvite(userId: Data) -> Bool {
            nil != invalidInviteMap[userId]
        }

        // MARK: Member labels

        public mutating func setMemberLabel(label: MemberLabel?, aci: Aci) {
            memberLabels[aci] = label
        }

        // MARK: Build

        public func build() -> GroupMembership {
            owsAssertDebug(
                Set(bannedMembers.keys.lazy.map { SignalServiceAddress($0) })
                    .isDisjoint(with: Set(memberStates.keys)),
            )

            // TODO: Why is this here? Uggh.
            let memberStates = self.memberStates.filter {
                $0.key.phoneNumber != OWSUserProfile.Constants.localProfilePhoneNumber
            }

            return GroupMembership(
                memberStates: memberStates,
                bannedMembers: bannedMembers,
                invalidInviteMap: invalidInviteMap,
                memberLabels: memberLabels,
            )
        }
    }

    // MARK: - Local user accessors

    /// The local PNI, if it is present and an invited member.
    ///
    /// - Note
    /// PNIs can only be invited members. Further note that profile keys are
    /// required for full and requesting members, and PNIs have no associated
    /// profile or profile key.
    private func localPniAsInvitedMember(localIdentifiers: LocalIdentifiers) -> Pni? {
        if let localPni = localIdentifiers.pni, isInvitedMember(localPni) {
            return localPni
        }

        return nil
    }

    public var isLocalUserMemberOfAnyKind: Bool {
        guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
            return false
        }

        if isMemberOfAnyKind(localIdentifiers.aciAddress) {
            return true
        }

        return localPniAsInvitedMember(localIdentifiers: localIdentifiers) != nil
    }

    public var isLocalUserFullMember: Bool {
        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            return false
        }

        return isFullMember(localAci)
    }

    public var localUserMemberLabel: MemberLabel? {
        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            return nil
        }

        return memberLabel(for: localAci)
    }

    /// The ID at which the local user is invited, if at all.
    ///
    /// Checks membership for the local ACI first. If none is available, falls
    /// back to checking membership for the local PNI.
    public func localUserInvitedAtServiceId(localIdentifiers: LocalIdentifiers) -> ServiceId? {
        if isMemberOfAnyKind(localIdentifiers.aci) {
            // If our ACI is any kind of member, return that membership rather
            // than falling back to the PNI.
            if isInvitedMember(localIdentifiers.aci) {
                return localIdentifiers.aci
            }

            return nil
        }

        return localPniAsInvitedMember(localIdentifiers: localIdentifiers)
    }

    /// Whether the local user is an invited member.
    ///
    /// Checks membership for the local ACI first. If none is available, falls
    /// back to checking membership for the local PNI.
    public var isLocalUserInvitedMember: Bool {
        guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
            return false
        }

        return localUserInvitedAtServiceId(localIdentifiers: localIdentifiers) != nil
    }

    public var isLocalUserRequestingMember: Bool {
        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            return false
        }

        return isRequestingMember(localAci)
    }

    public var isLocalUserFullOrInvitedMember: Bool {
        return isLocalUserFullMember || isLocalUserInvitedMember
    }

    public var isLocalUserFullMemberAndAdministrator: Bool {
        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
            return false
        }

        return isFullMemberAndAdministrator(localAci)
    }
}

// MARK: - InvalidInviteModel

@objc(GroupMembershipInvalidInviteModel)
private final class InvalidInviteModel: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }

    init?(coder: NSCoder) {
        self.addedByUserId = coder.decodeObject(of: NSData.self, forKey: "addedByUserId") as Data?
        self.userId = coder.decodeObject(of: NSData.self, forKey: "userId") as Data?
    }

    func encode(with coder: NSCoder) {
        if let addedByUserId {
            coder.encode(addedByUserId, forKey: "addedByUserId")
        }
        if let userId {
            coder.encode(userId, forKey: "userId")
        }
    }

    override var hash: Int {
        var hasher = Hasher()
        hasher.combine(addedByUserId)
        hasher.combine(userId)
        return hasher.finalize()
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let object = object as? Self else { return false }
        guard type(of: self) == type(of: object) else { return false }
        guard self.addedByUserId == object.addedByUserId else { return false }
        guard self.userId == object.userId else { return false }
        return true
    }

    let userId: Data?
    let addedByUserId: Data?

    init(userId: Data?, addedByUserId: Data? = nil) {
        self.userId = userId
        self.addedByUserId = addedByUserId
    }
}

// MARK: - LegacyMemberState

@objc(_TtCC16SignalServiceKit15GroupMembership11MemberState)
private final class LegacyMemberState: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }

    init?(coder: NSCoder) {
        self.addedByUuid = coder.decodeObject(of: NSUUID.self, forKey: "addedByUuid") as UUID?
        self.isPending = coder.decodeObject(of: NSNumber.self, forKey: "isPending")?.boolValue ?? false
        self.role = (coder.decodeObject(of: NSNumber.self, forKey: "role")?.uintValue).flatMap(TSGroupMemberRole.init(rawValue:)) ?? .normal
    }

    func encode(with coder: NSCoder) {
        if let addedByUuid {
            coder.encode(addedByUuid, forKey: "addedByUuid")
        }
        coder.encode(NSNumber(value: self.isPending), forKey: "isPending")
        coder.encode(NSNumber(value: self.role.rawValue), forKey: "role")
    }

    override var hash: Int {
        var hasher = Hasher()
        hasher.combine(addedByUuid)
        hasher.combine(isPending)
        hasher.combine(role)
        return hasher.finalize()
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let object = object as? Self else { return false }
        guard type(of: self) == type(of: object) else { return false }
        guard self.addedByUuid == object.addedByUuid else { return false }
        guard self.isPending == object.isPending else { return false }
        guard self.role == object.role else { return false }
        return true
    }

    let role: TSGroupMemberRole
    let isPending: Bool
    // Only applies for pending members.
    let addedByUuid: UUID?

    init(role: TSGroupMemberRole, isPending: Bool, addedByUuid: UUID? = nil) {
        self.role = role
        self.isPending = isPending
        self.addedByUuid = addedByUuid
    }

    @objc
    var isAdministrator: Bool {
        return role == .administrator
    }
}