diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift index 5c2454304e..7267be9173 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift @@ -103,11 +103,11 @@ extension ConversationViewController: MessageRequestDelegate { return } let groupMembership = groupThread.groupModel.groupMembership - guard groupMembership.isPendingMember(localAddress) else { + guard groupMembership.isPendingProfileKeyMember(localAddress) else { owsFailDebug("Can't reject invite if not pending.") return } - guard let addedByUuid = groupMembership.addedByUuid(forPendingMember: localAddress) else { + guard let addedByUuid = groupMembership.addedByUuid(forPendingProfileKeyMember: localAddress) else { owsFailDebug("Missing addedByUuid.") return } @@ -193,7 +193,7 @@ extension ConversationViewController: MessageRequestDelegate { var isMemberOfGroup = false if let groupThread = thread as? TSGroupThread { - isMemberOfGroup = groupThread.isLocalUserInGroup + isMemberOfGroup = groupThread.isLocalUserMemberOfAnyKind } if isMemberOfGroup { @@ -232,7 +232,7 @@ extension ConversationViewController: MessageRequestDelegate { } guard let groupThread = thread as? TSGroupThread, - groupThread.isLocalUserPendingOrNonPendingMember else { + groupThread.isLocalUserFullOrInvitedMemberOfGroup else { // If we don't need to leave the group, finish up immediately. return completion() } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 3e5e335974..3ebc8edf15 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -557,7 +557,7 @@ typedef enum : NSUInteger { } TSGroupThread *groupThread = (TSGroupThread *)self.thread; - return !groupThread.isLocalUserInGroup; + return !groupThread.isLocalUserFullMemberOfGroup; } - (void)updateInputVisibility diff --git a/Signal/src/ViewControllers/ConversationView/MessageRequestView.swift b/Signal/src/ViewControllers/ConversationView/MessageRequestView.swift index 98cac0e3cc..8bcefb559c 100644 --- a/Signal/src/ViewControllers/ConversationView/MessageRequestView.swift +++ b/Signal/src/ViewControllers/ConversationView/MessageRequestView.swift @@ -56,7 +56,7 @@ class MessageRequestView: UIStackView { if let groupThread = thread as? TSGroupThread, groupThread.isGroupV2Thread { - self.mode = (groupThread.isLocalUserPendingMember + self.mode = (groupThread.isLocalUserPendingProfileKeyMember ? .groupInviteRequest : .contactOrGroupRequest) } else { @@ -262,7 +262,7 @@ class MessageRequestView: UIStackView { return UILabel() } let groupMembership = groupThread.groupModel.groupMembership - guard let addedByUuid = groupMembership.addedByUuid(forPendingMember: localAddress) else { + guard let addedByUuid = groupMembership.addedByUuid(forPendingProfileKeyMember: localAddress) else { owsFailDebug("missing addedByUuid") return UILabel() } diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift b/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift index f3b1ff8b9e..81f27b5d1d 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift @@ -172,7 +172,7 @@ class DebugUIGroupsV2: DebugUIPage { var defaultModelBuilder = TSGroupModelBuilder() defaultModelBuilder.groupsVersion = groupsVersion var defaultMembershipBuilder = GroupMembership.Builder() - defaultMembershipBuilder.addNonPendingMember(localAddress, role: .administrator) + defaultMembershipBuilder.addFullMember(localAddress, role: .administrator) defaultModelBuilder.groupMembership = defaultMembershipBuilder.build() let defaultModel = try defaultModelBuilder.build(transaction: transaction) let defaultDMToken = DisappearingMessageToken.disabledToken @@ -240,10 +240,10 @@ class DebugUIGroupsV2: DebugUIPage { modelBuilder2.name = "name 2" modelBuilder2.avatarData = "avatar 2".data(using: .utf8) var groupMembershipBuilder1 = model1.groupMembership.asBuilder - groupMembershipBuilder1.addNonPendingMember(otherAddress0, role: .normal) + groupMembershipBuilder1.addFullMember(otherAddress0, role: .normal) if groupsVersion == .V2, let updaterUuid = updaterAddress?.uuid { - groupMembershipBuilder1.addPendingMember(otherAddress1, + groupMembershipBuilder1.addPendingProfileKeyMember(otherAddress1, role: .normal, addedByUuid: updaterUuid) } @@ -371,7 +371,7 @@ class DebugUIGroupsV2: DebugUIPage { var groupMembershipBuilder1 = defaultModel.groupMembership.asBuilder for member in members { groupMembershipBuilder1.remove(member) - groupMembershipBuilder1.addNonPendingMember(member, role: .normal) + groupMembershipBuilder1.addFullMember(member, role: .normal) } modelBuilder1.groupMembership = groupMembershipBuilder1.build() let model1 = try modelBuilder1.build(transaction: transaction) @@ -380,7 +380,7 @@ class DebugUIGroupsV2: DebugUIPage { var groupMembershipBuilder2 = defaultModel.groupMembership.asBuilder for member in members { groupMembershipBuilder2.remove(member) - groupMembershipBuilder2.addNonPendingMember(member, role: .administrator) + groupMembershipBuilder2.addFullMember(member, role: .administrator) } modelBuilder2.groupMembership = groupMembershipBuilder2.build() let model2 = try modelBuilder2.build(transaction: transaction) @@ -425,7 +425,7 @@ class DebugUIGroupsV2: DebugUIPage { var groupMembershipBuilder2 = defaultModel.groupMembership.asBuilder for member in members { groupMembershipBuilder2.remove(member) - groupMembershipBuilder2.addNonPendingMember(member, role: .administrator) + groupMembershipBuilder2.addFullMember(member, role: .administrator) } modelBuilder2.groupMembership = groupMembershipBuilder2.build() let model2 = try modelBuilder2.build(transaction: transaction) @@ -483,7 +483,7 @@ class DebugUIGroupsV2: DebugUIPage { var groupMembershipBuilder2 = defaultModel.groupMembership.asBuilder for member in members { groupMembershipBuilder2.remove(member) - groupMembershipBuilder2.addPendingMember(member, role: .normal, addedByUuid: inviterUuid) + groupMembershipBuilder2.addPendingProfileKeyMember(member, role: .normal, addedByUuid: inviterUuid) } modelBuilder2.groupMembership = groupMembershipBuilder2.build() let model2 = try modelBuilder2.build(transaction: transaction) @@ -493,7 +493,7 @@ class DebugUIGroupsV2: DebugUIPage { var groupMembershipBuilder3 = defaultModel.groupMembership.asBuilder for member in members { groupMembershipBuilder3.remove(member) - groupMembershipBuilder3.addNonPendingMember(member, role: .administrator) + groupMembershipBuilder3.addFullMember(member, role: .administrator) } modelBuilder3.groupMembership = groupMembershipBuilder3.build() let model3 = try modelBuilder3.build(transaction: transaction) @@ -540,7 +540,7 @@ class DebugUIGroupsV2: DebugUIPage { let oldGroupMembership = oldGroupModel.groupMembership var groupMembershipBuilder = oldGroupMembership.asBuilder - for address in oldGroupMembership.allUsers { + for address in oldGroupMembership.allMembersOfAnyKind { if address != localAddress { groupMembershipBuilder.remove(address) } @@ -609,7 +609,7 @@ class DebugUIGroupsV2: DebugUIPage { guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else { throw OWSAssertionError("Invalid groupModel.") } - guard groupModel.groupMembership.isNonPendingMember(otherUserAddress) else { + guard groupModel.groupMembership.isFullMember(otherUserAddress) else { throw OWSAssertionError("Other user is not a full member.") } // Last admin (local user) can't leave group, so first diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIStress.swift b/Signal/src/ViewControllers/DebugUI/DebugUIStress.swift index b09ca65457..d00a1d0309 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIStress.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIStress.swift @@ -121,12 +121,12 @@ public extension DebugUIStress { var groupMembershipBuilder = oldGroupMembership.asBuilder for address in membersToAdd { assert(address.isValid) - guard !oldGroupMembership.isPendingOrNonPendingMember(address) else { + guard !oldGroupMembership.isMemberOfAnyKind(address) else { Logger.warn("Recipient is already in group.") continue } // GroupManager will separate out members as pending if necessary. - groupMembershipBuilder.addNonPendingMember(address, role: .normal) + groupMembershipBuilder.addFullMember(address, role: .normal) } builder.groupMembership = groupMembershipBuilder.build() return try builder.build(transaction: transaction) diff --git a/Signal/src/ViewControllers/HomeView/ConversationListViewController.m b/Signal/src/ViewControllers/HomeView/ConversationListViewController.m index 860d230635..9f56f21541 100644 --- a/Signal/src/ViewControllers/HomeView/ConversationListViewController.m +++ b/Signal/src/ViewControllers/HomeView/ConversationListViewController.m @@ -1671,7 +1671,7 @@ NSString *const kArchiveButtonPseudoGroup = @"kArchiveButtonPseudoGroup"; DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { if ([thread isKindOfClass:[TSGroupThread class]]) { TSGroupThread *groupThread = (TSGroupThread *)thread; - if (groupThread.isLocalUserInGroup || groupThread.isGroupV2Thread) { + if (groupThread.isLocalUserMemberOfAnyKind || groupThread.isGroupV2Thread) { [groupThread softDeleteThreadWithTransaction:transaction]; } else { [groupThread anyRemoveWithTransaction:transaction]; diff --git a/Signal/src/ViewControllers/NewGroupView/NewGroupConfirmViewController.swift b/Signal/src/ViewControllers/NewGroupView/NewGroupConfirmViewController.swift index da9c3c1c1e..46e5977607 100644 --- a/Signal/src/ViewControllers/NewGroupView/NewGroupConfirmViewController.swift +++ b/Signal/src/ViewControllers/NewGroupView/NewGroupConfirmViewController.swift @@ -305,7 +305,7 @@ public class NewGroupConfirmViewController: OWSViewController { self.presentingViewController?.dismiss(animated: true, completion: completion) } - let pendingMembers = groupThread.groupModel.groupMembership.pendingMembers + let pendingMembers = groupThread.groupModel.groupMembership.pendingProfileKeyMembers guard let firstPendingMember = pendingMembers.first else { // No pending members. return navigateToNewGroup(nil) diff --git a/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift b/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift index 2f85b0d310..8032231fc1 100644 --- a/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift @@ -137,13 +137,13 @@ private extension AddGroupMembersViewController { return nil } for address in addresses { - guard !oldGroupMembership.isNonPendingMember(address) else { + guard !oldGroupMembership.isFullMember(address) else { owsFailDebug("Recipient is already in group.") continue } // GroupManager will separate out members as pending if necessary. groupMembershipBuilder.remove(address) - groupMembershipBuilder.addNonPendingMember(address, role: .normal) + groupMembershipBuilder.addFullMember(address, role: .normal) } builder.groupMembership = groupMembershipBuilder.build() return try builder.build(transaction: transaction) @@ -248,7 +248,7 @@ extension AddGroupMembersViewController: GroupMemberViewDelegate { } func groupMemberViewGroupMemberCountForDisplay() -> Int { - return (oldGroupModel.groupMembership.pendingAndNonPendingMemberCount + + return (oldGroupModel.groupMembership.allMembersOfAnyKind.count + newRecipientSet.count) } @@ -270,11 +270,13 @@ extension AddGroupMembersViewController: GroupMemberViewDelegate { owsFailDebug("Invalid recipient.") return false } - if oldGroupModel.groupMembership.isNonPendingMember(address) { + let groupMembership = oldGroupModel.groupMembership + if groupMembership.isFullMember(address) { return true } - if oldGroupModel.groupMembership.isPendingMember(address) { - // We can "add" pending members if they support gv2 + if groupMembership.isPendingProfileKeyMember(address) || + groupMembership.isRequestingMember(address) { + // We can "add" pending or requesting members if they support gv2 // and we know their profile key credential. let canAddMember = databaseStorage.read { transaction -> Bool in guard GroupManager.doesUserSupportGroupsV2(address: address, transaction: transaction) else { diff --git a/Signal/src/ViewControllers/ThreadSettings/AddToGroupViewController.swift b/Signal/src/ViewControllers/ThreadSettings/AddToGroupViewController.swift index 8621a0e1bc..3541ec31f5 100644 --- a/Signal/src/ViewControllers/ThreadSettings/AddToGroupViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/AddToGroupViewController.swift @@ -147,7 +147,7 @@ public class AddToGroupViewController: OWSTableViewController { return Environment.shared.contactsManager.shortDisplayName(for: self.address, transaction: transaction) } - guard !groupThread.groupModel.groupMembership.isPendingOrNonPendingMember(address) else { + guard !groupThread.groupModel.groupMembership.isMemberOfAnyKind(address) else { let toastFormat = NSLocalizedString( "ADD_TO_GROUP_ALREADY_MEMBER_TOAST_FORMAT", comment: "A toast on the 'add to group' view indicating the user is already a member. Embeds {contact name} and {group name}" @@ -220,12 +220,12 @@ public class AddToGroupViewController: OWSTableViewController { let oldGroupMembership = oldGroupModel.groupMembership var groupMembershipBuilder = oldGroupMembership.asBuilder - guard !oldGroupMembership.isPendingOrNonPendingMember(self.address) else { + guard !oldGroupMembership.isMemberOfAnyKind(self.address) else { owsFailDebug("Recipient is already in group.") return nil } // GroupManager will separate out members as pending if necessary. - groupMembershipBuilder.addNonPendingMember(self.address, role: .normal) + groupMembershipBuilder.addFullMember(self.address, role: .normal) builder.groupMembership = groupMembershipBuilder.build() return try builder.build(transaction: transaction) diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift index c75c5f9005..ae73442007 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift @@ -199,7 +199,7 @@ extension ConversationSettingsViewController { return } - let isLocalUserInConversation = self.isLocalUserInConversation + let isLocalUserFullMemberOfGroup = self.isLocalUserFullMemberOfGroup // For pre-message request threads, allow manually sharing your profile if the thread is not whitelisted. let (isPreMessageRequestsThread, isThreadInProfileWhitelist) = databaseStorage.uiRead { transaction -> (Bool, Bool) in @@ -225,7 +225,7 @@ extension ConversationSettingsViewController { let cell = OWSTableItem.buildDisclosureCell(name: title, icon: .settingsProfile, accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "share_profile")) - cell.isUserInteractionEnabled = isLocalUserInConversation + cell.isUserInteractionEnabled = isLocalUserFullMemberOfGroup return cell }, actionBlock: { [weak self] in @@ -472,7 +472,7 @@ extension ConversationSettingsViewController { : NSLocalizedString("CONVERSATION_SETTINGS_BLOCK_AND_LEAVE_SECTION_CONTACT_FOOTER", comment: "Footer text for the 'block and leave' section of contact conversation settings view.") - if isGroupThread, isLocalUserInConversation { + if isGroupThread, isLocalUserFullOrInvitedMemberOfGroup { section.add(OWSTableItem(customCellBlock: { [weak self] in guard let self = self else { owsFailDebug("Missing self") @@ -592,7 +592,7 @@ extension ConversationSettingsViewController { private func accessoryLabel(forAccess access: GroupV2Access) -> String { switch access { - case .unknown, .any, .member: + case .any, .member: if access != .member { owsFailDebug("Invalid attributes access: \(access.rawValue)") } @@ -600,7 +600,11 @@ extension ConversationSettingsViewController { comment: "Label indicating that all group members can update the group's attributes: name, avatar, etc.") case .administrator: return NSLocalizedString("CONVERSATION_SETTINGS_ATTRIBUTES_ACCESS_ADMINISTRATOR", - comment: "Label indicating that only administrators can update the group's attributes: name, avatar, etc.") + comment: "Label indicating that only administrators can update the group's attributes: name, avatar, etc.") + case .unknown, .unsatisfiable: + owsFailDebug("Invalid access") + return NSLocalizedString("CONVERSATION_SETTINGS_ATTRIBUTES_ACCESS_NONE", + comment: "Label indicating that no member can update the group's attributes: name, avatar, etc.") } } @@ -650,7 +654,7 @@ extension ConversationSettingsViewController { } let groupMembership = groupModel.groupMembership - let allMembers = groupMembership.nonPendingMembers + let allMembers = groupMembership.fullMembers var allMembersSorted = [SignalServiceAddress]() var verificationStateMap = [SignalServiceAddress: OWSVerificationState]() databaseStorage.uiRead { transaction in @@ -663,15 +667,15 @@ extension ConversationSettingsViewController { } var membersToRender = [SignalServiceAddress]() - if groupMembership.isNonPendingMember(localAddress) { + if groupMembership.isFullMember(localAddress) { // Make sure local user is first. membersToRender.insert(localAddress, at: 0) } // Admin users are second. - let adminMembers = allMembersSorted.filter { $0 != localAddress && groupMembership.isAdministrator($0) } + let adminMembers = allMembersSorted.filter { $0 != localAddress && groupMembership.isFullMemberAndAdministrator($0) } membersToRender += adminMembers // Non-admin users are third. - let nonAdminMembers = allMembersSorted.filter { $0 != localAddress && !groupMembership.isAdministrator($0) } + let nonAdminMembers = allMembersSorted.filter { $0 != localAddress && !groupMembership.isFullMemberAndAdministrator($0) } membersToRender += nonAdminMembers if membersToRender.count > 1 { @@ -708,7 +712,7 @@ extension ConversationSettingsViewController { let cell = ContactTableViewCell() cell.setUseSmallAvatars() - let isGroupAdmin = groupMembership.isAdministrator(memberAddress) + let isGroupAdmin = groupMembership.isFullMemberAndAdministrator(memberAddress) let isVerified = verificationState == .verified let isNoLongerVerified = verificationState == .noLongerVerified let isBlocked = helper.isSignalServiceAddressBlocked(memberAddress) @@ -794,6 +798,7 @@ extension ConversationSettingsViewController { return section } + // TODO: Add requesting members section. private func buildPendingMembersSection(groupModel: TSGroupModel, localAddress: SignalServiceAddress) -> OWSTableSection { let section = OWSTableSection() @@ -803,7 +808,7 @@ extension ConversationSettingsViewController { // Only admins can revoke invites. let canRevokeInvites = groupViewHelper.canRevokePendingInvites - let pendingMembers = groupModel.groupMembership.pendingMembers + let pendingMembers = groupModel.groupMembership.pendingProfileKeyMembers let pendingInviteCount = (canRevokeInvites ? pendingMembers.count + groupModel.groupMembership.invalidInvites.count : pendingMembers.count) @@ -815,7 +820,7 @@ extension ConversationSettingsViewController { accessoryText = NSLocalizedString("CONVERSATION_SETTINGS_PENDING_MEMBER_INVITES_NONE", comment: "Indicates that there are no pending member invites in the group.") } - let isLocalUserFullMember = groupModel.groupMembership.isNonPendingMember(localAddress) + let isLocalUserFullMember = groupModel.groupMembership.isFullMember(localAddress) section.add(OWSTableItem(customCellBlock: { [weak self] in guard let self = self else { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift index 2434cb4882..d6e539faea 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift @@ -172,7 +172,7 @@ extension ConversationSettingsViewController { private func buildHeaderForGroup(groupThread: TSGroupThread) -> UIView { var builder = HeaderBuilder(viewController: self) - let memberCount = groupThread.groupModel.groupMembership.nonPendingMembers.count + let memberCount = groupThread.groupModel.groupMembership.fullMembers.count var groupMembersText = GroupViewUtils.formatGroupMembersLabel(memberCount: memberCount) if RemoteConfig.groupsV2GoodCitizen, groupThread.isGroupV1Thread { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index 2363c9f1a8..def09b12df 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -135,8 +135,12 @@ class ConversationSettingsViewController: OWSTableViewController { return groupViewHelper.canEditConversationAccess } - var isLocalUserInConversation: Bool { - return groupViewHelper.isLocalUserInConversation + var isLocalUserFullMemberOfGroup: Bool { + return groupViewHelper.isLocalUserFullMemberOfGroup + } + + var isLocalUserFullOrInvitedMemberOfGroup: Bool { + return groupViewHelper.isLocalUserFullOrInvitedMemberOfGroup } var isGroupThread: Bool { @@ -318,7 +322,7 @@ class ConversationSettingsViewController: OWSTableViewController { // Thread no longer exists. return false } - guard latestThread.isLocalUserInGroup else { + guard latestThread.isLocalUserMemberOfAnyKind else { // Local user is no longer in group, e.g. perhaps they just blocked it. return false } @@ -690,9 +694,8 @@ class ConversationSettingsViewController: OWSTableViewController { return false } let groupMembership = groupModelV2.groupMembership - guard groupMembership.isNonPendingMember(localAddress), - groupMembership.isAdministrator(localAddress), - groupMembership.nonPendingAdministrators.count == 1 else { + guard groupMembership.isFullMemberAndAdministrator(localAddress), + groupMembership.fullMemberAdministrators.count == 1 else { return false } return true @@ -710,7 +713,7 @@ class ConversationSettingsViewController: OWSTableViewController { owsFailDebug("missing local address") return [] } - var candidates = groupModelV2.groupMembership.nonPendingMembers + var candidates = groupModelV2.groupMembership.fullMembers candidates.remove(localAddress) return candidates } diff --git a/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper+MemberActionSheet.swift b/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper+MemberActionSheet.swift index 82d71ef8a4..674c8d2e46 100644 --- a/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper+MemberActionSheet.swift +++ b/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper+MemberActionSheet.swift @@ -50,10 +50,10 @@ extension GroupViewHelper { owsFailDebug("Missing localAddress.") return false } - let isLocalUserAdmin = groupThread.groupModel.groupMembership.isAdministrator(localAddress) + let isLocalUserAdmin = groupThread.groupModel.groupMembership.isFullMemberAndAdministrator(localAddress) let groupMembership = groupThread.groupModel.groupMembership - let canBecomeAdmin = (groupMembership.isNonPendingMember(address) && - !groupMembership.isAdministrator(address)) + let canBecomeAdmin = (groupMembership.isFullMember(address) && + !groupMembership.isFullMemberAndAdministrator(address)) return (canEditConversationMembership && isLocalUserAdmin && canBecomeAdmin) } @@ -73,7 +73,7 @@ extension GroupViewHelper { guard let oldGroupModel = delegate?.currentGroupModel as? TSGroupModelV2 else { return Promise(error: OWSAssertionError("Missing group model.")) } - guard oldGroupModel.groupMembership.isPendingOrNonPendingMember(address) else { + guard oldGroupModel.groupMembership.isMemberOfAnyKind(address) else { return Promise(error: OWSAssertionError("Not a group member.")) } guard let uuid = address.uuid else { @@ -98,10 +98,9 @@ extension GroupViewHelper { owsFailDebug("Missing localAddress.") return false } - let isLocalUserAdmin = groupThread.groupModel.groupMembership.isAdministrator(localAddress) let groupMembership = groupThread.groupModel.groupMembership - let canRevokeAdmin = (groupMembership.isNonPendingMember(address) && - groupMembership.isAdministrator(address)) + let isLocalUserAdmin = groupMembership.isFullMemberAndAdministrator(localAddress) + let canRevokeAdmin = groupMembership.isFullMemberAndAdministrator(address) return (canEditConversationMembership && isLocalUserAdmin && canRevokeAdmin) } @@ -121,7 +120,7 @@ extension GroupViewHelper { guard let oldGroupModel = delegate?.currentGroupModel as? TSGroupModelV2 else { return Promise(error: OWSAssertionError("Missing group model.")) } - guard oldGroupModel.groupMembership.isPendingOrNonPendingMember(address) else { + guard oldGroupModel.groupMembership.isMemberOfAnyKind(address) else { return Promise(error: OWSAssertionError("Not a group member.")) } guard let uuid = address.uuid else { @@ -148,9 +147,9 @@ extension GroupViewHelper { return false } // Only admins can kick out other members. - let isLocalUserAdmin = groupThread.groupModel.groupMembership.isAdministrator(localAddress) let groupMembership = groupThread.groupModel.groupMembership - let isAddressInGroup = groupMembership.isPendingOrNonPendingMember(address) + let isLocalUserAdmin = groupMembership.isFullMemberAndAdministrator(localAddress) + let isAddressInGroup = groupMembership.isMemberOfAnyKind(address) return canEditConversationMembership && isLocalUserAdmin && isAddressInGroup } @@ -170,7 +169,7 @@ extension GroupViewHelper { guard let oldGroupModel = delegate?.currentGroupModel as? TSGroupModelV2 else { return Promise(error: OWSAssertionError("Missing group model.")) } - guard oldGroupModel.groupMembership.isPendingOrNonPendingMember(address) else { + guard oldGroupModel.groupMembership.isMemberOfAnyKind(address) else { return Promise(error: OWSAssertionError("Not a group member.")) } guard let uuid = address.uuid else { diff --git a/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper.swift b/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper.swift index 8982a1712d..0d3b4b5750 100644 --- a/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper.swift +++ b/Signal/src/ViewControllers/ThreadSettings/GroupViewHelper.swift @@ -61,7 +61,7 @@ class GroupViewHelper: NSObject { if threadViewModel.hasPendingMessageRequest { return false } - guard isLocalUserInConversation else { + guard isLocalUserFullMemberOfGroup else { return false } guard let groupThread = thread as? TSGroupThread else { @@ -85,12 +85,15 @@ class GroupViewHelper: NSObject { case .unknown: owsFailDebug("Unknown access.") return false + case .unsatisfiable: + owsFailDebug("Invalid access.") + return false case .any: return true case .member: - return groupModelV2.groupMembership.isNonPendingMember(localAddress) + return groupModelV2.groupMembership.isFullMember(localAddress) case .administrator: - return (groupModelV2.groupMembership.isNonPendingMember(localAddress) && groupModelV2.groupMembership.isAdministrator(localAddress)) + return (groupModelV2.groupMembership.isFullMemberAndAdministrator(localAddress)) } } @@ -117,7 +120,7 @@ class GroupViewHelper: NSObject { if threadViewModel.hasPendingMessageRequest { return false } - guard isLocalUserInConversation else { + guard isLocalUserFullMemberOfGroup else { return false } guard let groupThread = thread as? TSGroupThread else { @@ -132,7 +135,7 @@ class GroupViewHelper: NSObject { owsFailDebug("Missing localAddress.") return false } - return groupModelV2.groupMembership.isAdministrator(localAddress) + return groupModelV2.groupMembership.isFullMemberAndAdministrator(localAddress) } var canRevokePendingInvites: Bool { @@ -145,20 +148,24 @@ class GroupViewHelper: NSObject { } let groupMembership = groupThread.groupModel.groupMembership return (!threadViewModel.hasPendingMessageRequest && - groupMembership.isPendingOrNonPendingMember(localAddress) && - groupMembership.isAdministrator(localAddress)) + groupMembership.isFullMemberAndAdministrator(localAddress)) } var canResendInvites: Bool { - return (!threadViewModel.hasPendingMessageRequest && - isLocalUserInConversation) + return (!threadViewModel.hasPendingMessageRequest && isLocalUserFullMemberOfGroup) } - var isLocalUserInConversation: Bool { + var isLocalUserFullMemberOfGroup: Bool { guard let groupThread = thread as? TSGroupThread else { return true } + return groupThread.isLocalUserFullMemberOfGroup + } - return groupThread.isLocalUserInGroup + var isLocalUserFullOrInvitedMemberOfGroup: Bool { + guard let groupThread = thread as? TSGroupThread else { + return true + } + return groupThread.isLocalUserFullOrInvitedMemberOfGroup } } diff --git a/Signal/src/ViewControllers/ThreadSettings/PendingGroupMembersViewController.swift b/Signal/src/ViewControllers/ThreadSettings/PendingGroupMembersViewController.swift index f36c8b5a76..8fc742d41a 100644 --- a/Signal/src/ViewControllers/ThreadSettings/PendingGroupMembersViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/PendingGroupMembersViewController.swift @@ -69,7 +69,7 @@ public class PendingGroupMembersViewController: OWSTableViewController { let groupMembership = groupModel.groupMembership let allPendingMembersSorted = databaseStorage.uiRead { transaction in - self.contactsManager.sortSignalServiceAddresses(Array(groupMembership.pendingMembers), + self.contactsManager.sortSignalServiceAddresses(Array(groupMembership.pendingProfileKeyMembers), transaction: transaction) } @@ -77,7 +77,7 @@ public class PendingGroupMembersViewController: OWSTableViewController { var membersInvitedByLocalUser = [SignalServiceAddress]() var membersInvitedByOtherUsers = [SignalServiceAddress: [SignalServiceAddress]]() for invitedAddress in allPendingMembersSorted { - guard let inviterUuid = groupMembership.addedByUuid(forPendingMember: invitedAddress) else { + guard let inviterUuid = groupMembership.addedByUuid(forPendingProfileKeyMember: invitedAddress) else { owsFailDebug("Missing inviter.") continue } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 3e3f21081b..3386864a45 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -802,6 +802,9 @@ /* Label indicating that all group members can update the group's attributes: name, avatar, etc. */ "CONVERSATION_SETTINGS_ATTRIBUTES_ACCESS_MEMBER" = "All"; +/* Label indicating that no member can update the group's attributes: name, avatar, etc. */ +"CONVERSATION_SETTINGS_ATTRIBUTES_ACCESS_NONE" = "Disabled"; + /* Footer for the 'attributes access' section in conversation settings view. */ "CONVERSATION_SETTINGS_ATTRIBUTES_ACCESS_SECTION_FOOTER" = "Choose who can edit the group name, photo, and disappearing messages timer."; @@ -1480,6 +1483,9 @@ /* Description of the 'unknown' access level. */ "GROUP_ACCESS_LEVEL_UNKNOWN" = "Unknown"; +/* Description of the 'unsatisfiable' access level. */ +"GROUP_ACCESS_LEVEL_UNSATISFIABLE" = "Disabled"; + /* Message indicating that the access to the group's members was changed by the local user. Embeds {{new access level}}. */ "GROUP_ACCESS_MEMBERS_UPDATED_BY_LOCAL_USER_FORMAT" = "You changed who can edit group membership to “%@“."; @@ -1591,6 +1597,15 @@ /* Message indicating that the local user was removed from the group by an unknown user. */ "GROUP_LOCAL_USER_REMOVED_BY_UNKNOWN_USER" = "You were removed from the group."; +/* Message indicating that the local user's request to join the group was approved. */ +"GROUP_LOCAL_USER_REQUEST_APPROVED" = "GROUP_LOCAL_USER_REQUEST_APPROVED"; + +/* Message indicating that the local user's request to join the group was rejected. */ +"GROUP_LOCAL_USER_REQUEST_REJECTED" = "GROUP_LOCAL_USER_REQUEST_REJECTED"; + +/* Message indicating that the local user requested to join the group. */ +"GROUP_LOCAL_USER_REQUESTED_TO_JOIN_TO_THE_GROUP" = "GROUP_LOCAL_USER_REQUESTED_TO_JOIN_TO_THE_GROUP"; + /* Message indicating that the local user had their administrator role revoked. */ "GROUP_LOCAL_USER_REVOKED_ADMINISTRATOR" = "Your admin privileges were revoked."; @@ -1714,6 +1729,27 @@ /* Message indicating that the remote user was removed from the group. Embeds {{ %1$@ user who removed the user, %2$@ user who was removed}}. */ "GROUP_REMOTE_USER_REMOVED_FROM_GROUP_BY_REMOTE_USER_FORMAT" = "%1$@ removed %2$@."; +/* Message indicating that a remote user's request to join the group was approved by the local user. Embeds {{requesting user name}}. */ +"GROUP_REMOTE_USER_REQUEST_APPROVED_BY_LOCAL_USER_FORMAT" = "GROUP_REMOTE_USER_REQUEST_APPROVED_BY_LOCAL_USER_FORMAT"; + +/* Message indicating that a remote user's request to join the group was approved by another user. Embeds {{ %1$@ requesting user name, %2$@ approving user name }}. */ +"GROUP_REMOTE_USER_REQUEST_APPROVED_BY_REMOTE_USER_FORMAT" = "GROUP_REMOTE_USER_REQUEST_APPROVED_BY_REMOTE_USER_FORMAT"; + +/* Message indicating that a remote user's request to join the group was approved. Embeds {{requesting user name}}. */ +"GROUP_REMOTE_USER_REQUEST_APPROVED_FORMAT" = "GROUP_REMOTE_USER_REQUEST_APPROVED_FORMAT"; + +/* Message indicating that a remote user's request to join the group was rejected by the local user. Embeds {{requesting user name}}. */ +"GROUP_REMOTE_USER_REQUEST_REJECTED_BY_LOCAL_USER_FORMAT" = "GROUP_REMOTE_USER_REQUEST_REJECTED_BY_LOCAL_USER_FORMAT"; + +/* Message indicating that a remote user's request to join the group was rejected by another user. Embeds {{ %1$@ requesting user name, %2$@ approving user name }}. */ +"GROUP_REMOTE_USER_REQUEST_REJECTED_BY_REMOTE_USER_FORMAT" = "GROUP_REMOTE_USER_REQUEST_REJECTED_BY_REMOTE_USER_FORMAT"; + +/* Message indicating that a remote user's request to join the group was rejected. Embeds {{requesting user name}}. */ +"GROUP_REMOTE_USER_REQUEST_REJECTED_FORMAT" = "GROUP_REMOTE_USER_REQUEST_REJECTED_FORMAT"; + +/* Message indicating that a remote user requested to join the group. Embeds {{requesting user name}}. */ +"GROUP_REMOTE_USER_REQUESTED_TO_JOIN_THE_GROUP_FORMAT" = "GROUP_REMOTE_USER_REQUESTED_TO_JOIN_THE_GROUP_FORMAT"; + /* Message indicating that a remote user had their administrator role revoked. Embeds {{remote user name}}. */ "GROUP_REMOTE_USER_REVOKED_ADMINISTRATOR" = "%@ had their admin privileges revoked."; diff --git a/SignalMessaging/groups/GroupV2SnapshotImpl.swift b/SignalMessaging/groups/GroupV2SnapshotImpl.swift index 8c5496cf00..47f9f1c3ce 100644 --- a/SignalMessaging/groups/GroupV2SnapshotImpl.swift +++ b/SignalMessaging/groups/GroupV2SnapshotImpl.swift @@ -44,8 +44,9 @@ public struct GroupV2SnapshotImpl: GroupV2Snapshot { private let pendingMembers: [PendingMember] private let invalidInvites: [InvalidInvite] - public let accessControlForAttributes: GroupsProtoAccessControlAccessRequired - public let accessControlForMembers: GroupsProtoAccessControlAccessRequired + public let groupAccess: GroupAccess + + public let inviteLinkPassword: Data? public let disappearingMessageToken: DisappearingMessageToken @@ -64,8 +65,8 @@ public struct GroupV2SnapshotImpl: GroupV2Snapshot { members: [Member], pendingMembers: [PendingMember], invalidInvites: [InvalidInvite], - accessControlForAttributes: GroupsProtoAccessControlAccessRequired, - accessControlForMembers: GroupsProtoAccessControlAccessRequired, + groupAccess: GroupAccess, + inviteLinkPassword: Data?, disappearingMessageToken: DisappearingMessageToken, profileKeys: [UUID: Data]) { @@ -78,8 +79,8 @@ public struct GroupV2SnapshotImpl: GroupV2Snapshot { self.members = members self.pendingMembers = pendingMembers self.invalidInvites = invalidInvites - self.accessControlForAttributes = accessControlForAttributes - self.accessControlForMembers = accessControlForMembers + self.groupAccess = groupAccess + self.inviteLinkPassword = inviteLinkPassword self.disappearingMessageToken = disappearingMessageToken self.profileKeys = profileKeys } @@ -91,7 +92,7 @@ public struct GroupV2SnapshotImpl: GroupV2Snapshot { owsFailDebug("Invalid value: \(member.role.rawValue)") continue } - builder.addNonPendingMember(member.address, role: role) + builder.addFullMember(member.address, role: role) } for member in pendingMembers { @@ -99,7 +100,7 @@ public struct GroupV2SnapshotImpl: GroupV2Snapshot { owsFailDebug("Invalid value: \(member.role.rawValue)") continue } - builder.addPendingMember(member.address, role: role, addedByUuid: member.addedByUuid) + builder.addPendingProfileKeyMember(member.address, role: role, addedByUuid: member.addedByUuid) } for invalidInvite in invalidInvites { @@ -108,9 +109,4 @@ public struct GroupV2SnapshotImpl: GroupV2Snapshot { return builder.build() } - - public var groupAccess: GroupAccess { - return GroupAccess(members: GroupAccess.groupV2Access(forProtoAccess: accessControlForMembers), - attributes: GroupAccess.groupV2Access(forProtoAccess: accessControlForAttributes)) - } } diff --git a/SignalMessaging/groups/GroupV2UpdatesImpl.swift b/SignalMessaging/groups/GroupV2UpdatesImpl.swift index d83991b66f..6efe74b554 100644 --- a/SignalMessaging/groups/GroupV2UpdatesImpl.swift +++ b/SignalMessaging/groups/GroupV2UpdatesImpl.swift @@ -262,7 +262,7 @@ public class GroupV2UpdatesImpl: NSObject, GroupV2UpdatesSwift { owsFailDebug("Missing localAddress.") return false } - let isLocalUserInGroup = groupThread.groupModel.groupMembership.isPendingOrNonPendingMember(localAddress) + let isLocalUserInGroup = groupThread.isLocalUserFullOrInvitedMemberOfGroup // Auth errors are expected if we've left the group, // but we should still try to refresh so we can learn // if we've been re-added. diff --git a/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift b/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift index 2542484a7c..22ac2e599e 100644 --- a/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift +++ b/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift @@ -58,23 +58,27 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { // Non-nil if the title changed. // When clearing the title, this will be the empty string. - private var title: String? + private var newTitle: String? public var newAvatarData: Data? public var newAvatarUrlPath: String? private var shouldUpdateAvatar = false private var membersToAdd = [UUID: TSGroupMemberRole]() - // Pending or non-pending members to remove. + // Full, pending profile key or pending request members to remove. private var membersToRemove = [UUID]() private var membersToChangeRole = [UUID: TSGroupMemberRole]() - private var pendingMembersToAdd = [UUID: TSGroupMemberRole]() + private var pendingProfileKeyMembersToAdd = [UUID: TSGroupMemberRole]() private var invalidInvitesToRemove = [Data: InvalidInvite]() - private var pendingMembersToPromote = [UUID]() + private var pendingProfileKeyMembersToPromote = [UUID]() // These access properties should only be set if the value is changing. private var accessForMembers: GroupV2Access? private var accessForAttributes: GroupV2Access? + private var accessForAddFromInviteLink: GroupV2Access? + + public var newInviteLinkPassword: Data? + private var shouldUpdateInviteLinkPassword = false private var shouldLeaveGroupDeclineInvite = false private var shouldRevokeInvalidInvites = false @@ -135,15 +139,19 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { let oldGroupMembership = oldGroupModel.groupMembership let newGroupMembership = newGroupModel.groupMembership - let oldUserUuids = Set(oldGroupMembership.allUsers.compactMap { $0.uuid }) - let newUserUuids = Set(newGroupMembership.allUsers.compactMap { $0.uuid }) + let oldUserUuids = oldGroupMembership.allMemberUuidsOfAnyKind + let newUserUuids = newGroupMembership.allMemberUuidsOfAnyKind for uuid in newUserUuids.subtracting(oldUserUuids) { - let isAdministrator = newGroupMembership.isAdministrator(uuid) - let isPending = newGroupMembership.isPendingMember(uuid) + guard !newGroupMembership.isRequestingMember(uuid) else { + owsFailDebug("Pending request members should never be added by diffing models.") + continue + } + let isAdministrator = newGroupMembership.isFullOrInvitedAdministrator(uuid) + let isPending = newGroupMembership.isPendingProfileKeyMember(uuid) let role: TSGroupMemberRole = isAdministrator ? .administrator : .normal if isPending { - addPendingMember(uuid, role: role) + addPendingProfileKeyMember(uuid, role: role) } else { addMember(uuid, role: role) } @@ -160,17 +168,22 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } for uuid in oldUserUuids.intersection(newUserUuids) { - if oldGroupMembership.isPendingMember(uuid), - newGroupMembership.isNonPendingMember(uuid) { + if oldGroupMembership.isPendingProfileKeyMember(uuid), + newGroupMembership.isFullMember(uuid) { + addMember(uuid, role: .normal) + } else if oldGroupMembership.isRequestingMember(uuid), + newGroupMembership.isFullMember(uuid) { + // We only currently support accepting join requests + // with "normal" role. addMember(uuid, role: .normal) } } - let oldMemberUuids = Set(oldGroupMembership.nonPendingMembers.compactMap { $0.uuid }) - let newMemberUuids = Set(newGroupMembership.nonPendingMembers.compactMap { $0.uuid }) + let oldMemberUuids = Set(oldGroupMembership.fullMembers.compactMap { $0.uuid }) + let newMemberUuids = Set(newGroupMembership.fullMembers.compactMap { $0.uuid }) for uuid in oldMemberUuids.intersection(newMemberUuids) { - let oldIsAdministrator = oldGroupMembership.isAdministrator(uuid) - let newIsAdministrator = newGroupMembership.isAdministrator(uuid) + let oldIsAdministrator = oldGroupMembership.isFullMemberAndAdministrator(uuid) + let newIsAdministrator = newGroupMembership.isFullMemberAndAdministrator(uuid) guard oldIsAdministrator != newIsAdministrator else { continue } @@ -178,13 +191,20 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { changeRoleForMember(uuid, role: role) } + if oldGroupModel.inviteLinkPassword != newGroupModel.inviteLinkPassword { + setInviteLinkPassword(newGroupModel.inviteLinkPassword) + } + let oldAccess = oldGroupModel.access let newAccess = newGroupModel.access if oldAccess.members != newAccess.members { - self.accessForMembers = newAccess.members + setAccessForMembers(newAccess.members) } if oldAccess.attributes != newAccess.attributes { - self.accessForAttributes = newAccess.attributes + setAccessForAttributes(newAccess.attributes) + } + if oldAccess.addFromInviteLink != newAccess.addFromInviteLink { + setAccessForAddFromInviteLink(newAccess.addFromInviteLink) } let oldDisappearingMessageToken = oldDMConfiguration.asToken @@ -196,9 +216,17 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { @objc public func setTitle(_ value: String?) { - assert(self.title == nil) + assert(self.newTitle == nil) // Non-nil if the title changed. - self.title = value ?? "" + self.newTitle = value ?? "" + } + + @objc + public func setInviteLinkPassword(_ value: Data?) { + assert(self.newInviteLinkPassword == nil) + assert(!shouldUpdateInviteLinkPassword) + newInviteLinkPassword = value + shouldUpdateInviteLinkPassword = true } @objc @@ -234,9 +262,9 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } @objc - public func promotePendingMember(_ uuid: UUID) { - assert(!pendingMembersToPromote.contains(uuid)) - pendingMembersToPromote.append(uuid) + public func promotePendingProfileKeyMember(_ uuid: UUID) { + assert(!pendingProfileKeyMembersToPromote.contains(uuid)) + pendingProfileKeyMembersToPromote.append(uuid) } public func changeRoleForMember(_ uuid: UUID, role: TSGroupMemberRole) { @@ -244,9 +272,9 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { membersToChangeRole[uuid] = role } - public func addPendingMember(_ uuid: UUID, role: TSGroupMemberRole) { - assert(pendingMembersToAdd[uuid] == nil) - pendingMembersToAdd[uuid] = role + public func addPendingProfileKeyMember(_ uuid: UUID, role: TSGroupMemberRole) { + assert(pendingProfileKeyMembersToAdd[uuid] == nil) + pendingProfileKeyMembersToAdd[uuid] = role } public func setShouldLeaveGroupDeclineInvite() { @@ -269,6 +297,11 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { accessForAttributes = value } + public func setAccessForAddFromInviteLink(_ value: GroupV2Access) { + assert(accessForAddFromInviteLink == nil) + accessForAddFromInviteLink = value + } + public func setNewDisappearingMessageToken(_ newDisappearingMessageToken: DisappearingMessageToken) { assert(self.newDisappearingMessageToken == nil) self.newDisappearingMessageToken = newDisappearingMessageToken @@ -313,7 +346,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { // NOTE: We don't (and can't) gather profile key credentials for pending members. var uuidsForProfileKeyCredentials = Set() uuidsForProfileKeyCredentials.formUnion(membersToAdd.keys) - uuidsForProfileKeyCredentials.formUnion(pendingMembersToPromote) + uuidsForProfileKeyCredentials.formUnion(pendingProfileKeyMembersToPromote) // This should be redundant, but we'll also double-check that we have // the local profile key credential. uuidsForProfileKeyCredentials.insert(localUuid) @@ -383,16 +416,16 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { Logger.verbose("Revision: \(oldRevision) -> \(newRevision)") actionsBuilder.setRevision(newRevision) - var nonPendingAdministratorCount: Int = currentGroupModel.groupMembership.nonPendingAdministrators.count - var allMemberCount = currentGroupModel.groupMembership.pendingAndNonPendingMemberCount + var fullMemberAdministratorCount: Int = currentGroupModel.groupMembership.fullMemberAdministrators.count + var allMemberCount = currentGroupModel.groupMembership.allMembersOfAnyKind.count var didChange = false - if let title = self.title { - if title == currentGroupModel.groupName { + if let newTitle = self.newTitle { + if newTitle == currentGroupModel.groupName { // Redundant change, not a conflict. } else { - let encryptedData = try groupV2Params.encryptGroupName(title) + let encryptedData = try groupV2Params.encryptGroupName(newTitle) let actionBuilder = GroupsProtoGroupChangeActionsModifyTitleAction.builder() actionBuilder.setTitle(encryptedData) actionsBuilder.setModifyTitle(try actionBuilder.build()) @@ -416,48 +449,76 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } } + if shouldUpdateInviteLinkPassword { + if newInviteLinkPassword == currentGroupModel.inviteLinkPassword { + // Redundant change, not a conflict. + } else { + let actionBuilder = GroupsProtoGroupChangeActionsModifyInviteLinkPasswordAction.builder() + if let newInviteLinkPassword = self.newInviteLinkPassword { + actionBuilder.setInviteLinkPassword(newInviteLinkPassword) + } + actionsBuilder.setModifyInviteLinkPassword(try actionBuilder.build()) + didChange = true + } + } + let currentGroupMembership = currentGroupModel.groupMembership - for (uuid, role) in self.membersToAdd { - guard !currentGroupMembership.isNonPendingMember(uuid) else { + for (uuid, role) in membersToAdd { + guard !currentGroupMembership.isFullMember(uuid) else { // Another user has already added this member. // They may have been added with a different role. // We don't treat that as a conflict. continue } - guard let profileKeyCredential = profileKeyCredentialMap[uuid] else { - throw OWSAssertionError("Missing profile key credential: \(uuid)") + if currentGroupMembership.isRequestingMember(uuid) { + let actionBuilder = GroupsProtoGroupChangeActionsPromoteRequestingMemberAction.builder() + let userId = try groupV2Params.userId(forUuid: uuid) + actionBuilder.setUserID(userId) + actionBuilder.setRole(role.asProtoRole) + actionsBuilder.addPromoteRequestingMembers(try actionBuilder.build()) + } else { + guard let profileKeyCredential = profileKeyCredentialMap[uuid] else { + throw OWSAssertionError("Missing profile key credential: \(uuid)") + } + let actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder() + actionBuilder.setAdded(try GroupsV2Protos.buildMemberProto(profileKeyCredential: profileKeyCredential, + role: role.asProtoRole, + groupV2Params: groupV2Params)) + actionsBuilder.addAddMembers(try actionBuilder.build()) } - let actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder() - actionBuilder.setAdded(try GroupsV2Protos.buildMemberProto(profileKeyCredential: profileKeyCredential, - role: role.asProtoRole, - groupV2Params: groupV2Params)) - actionsBuilder.addAddMembers(try actionBuilder.build()) didChange = true if role == .administrator { - nonPendingAdministratorCount += 1 + fullMemberAdministratorCount += 1 } } for uuid in self.membersToRemove { - if currentGroupMembership.isNonPendingMember(uuid) { + if currentGroupMembership.isFullMember(uuid) { let actionBuilder = GroupsProtoGroupChangeActionsDeleteMemberAction.builder() let userId = try groupV2Params.userId(forUuid: uuid) actionBuilder.setDeletedUserID(userId) actionsBuilder.addDeleteMembers(try actionBuilder.build()) didChange = true - if currentGroupMembership.isAdministrator(uuid) { - nonPendingAdministratorCount -= 1 + if currentGroupMembership.isFullMemberAndAdministrator(uuid) { + fullMemberAdministratorCount -= 1 } allMemberCount -= 1 - } else if currentGroupMembership.isPendingMember(uuid) { + } else if currentGroupMembership.isPendingProfileKeyMember(uuid) { let actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder() let userId = try groupV2Params.userId(forUuid: uuid) actionBuilder.setDeletedUserID(userId) actionsBuilder.addDeletePendingMembers(try actionBuilder.build()) didChange = true allMemberCount -= 1 + } else if currentGroupMembership.isRequestingMember(uuid) { + let actionBuilder = GroupsProtoGroupChangeActionsDeleteRequestingMemberAction.builder() + let userId = try groupV2Params.userId(forUuid: uuid) + actionBuilder.setDeletedUserID(userId) + actionsBuilder.addDeleteRequestingMembers(try actionBuilder.build()) + didChange = true + allMemberCount -= 1 } else { // Another user has already removed this member or revoked their // invitation. @@ -466,8 +527,8 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } } - for (uuid, role) in self.pendingMembersToAdd { - guard !currentGroupMembership.isPendingOrNonPendingMember(uuid) else { + for (uuid, role) in self.pendingProfileKeyMembersToAdd { + guard !currentGroupMembership.isMemberOfAnyKind(uuid) else { // Another user has already added or invited this member. // They may have been added with a different role. // We don't treat that as a conflict. @@ -516,7 +577,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } for (uuid, newRole) in self.membersToChangeRole { - guard currentGroupMembership.isNonPendingMember(uuid) else { + guard currentGroupMembership.isFullMember(uuid) else { // User is no longer a member. throw GroupsV2Error.conflictingChange } @@ -534,9 +595,9 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { didChange = true if currentRole == .administrator { - nonPendingAdministratorCount -= 1 + fullMemberAdministratorCount -= 1 } else if newRole == .administrator { - nonPendingAdministratorCount += 1 + fullMemberAdministratorCount += 1 } } @@ -546,7 +607,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { // Redundant change, not a conflict. } else { let actionBuilder = GroupsProtoGroupChangeActionsModifyMembersAccessControlAction.builder() - actionBuilder.setMembersAccess(GroupAccess.protoAccess(forGroupV2Access: access)) + actionBuilder.setMembersAccess(access.protoAccess) actionsBuilder.setModifyMemberAccess(try actionBuilder.build()) didChange = true } @@ -556,15 +617,25 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { // Redundant change, not a conflict. } else { let actionBuilder = GroupsProtoGroupChangeActionsModifyAttributesAccessControlAction.builder() - actionBuilder.setAttributesAccess(GroupAccess.protoAccess(forGroupV2Access: access)) + actionBuilder.setAttributesAccess(access.protoAccess) actionsBuilder.setModifyAttributesAccess(try actionBuilder.build()) didChange = true } } + if let access = self.accessForAddFromInviteLink { + if currentAccess.addFromInviteLink == access { + // Redundant change, not a conflict. + } else { + let actionBuilder = GroupsProtoGroupChangeActionsModifyAddFromInviteLinkAccessControlAction.builder() + actionBuilder.setAddFromInviteLinkAccess(access.protoAccess) + actionsBuilder.setModifyAddFromInviteLinkAccess(try actionBuilder.build()) + didChange = true + } + } - for uuid in pendingMembersToPromote { + for uuid in pendingProfileKeyMembersToPromote { // Check that pending member is still invited. - guard currentGroupMembership.isPendingMember(uuid) else { + guard currentGroupMembership.isPendingProfileKeyMember(uuid) else { throw GroupsV2Error.redundantChange } guard let profileKeyCredential = profileKeyCredentialMap[uuid] else { @@ -578,9 +649,8 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } if self.shouldLeaveGroupDeclineInvite { - let isLastAdminInV2Group = (currentGroupMembership.isNonPendingMember(localUuid) && - currentGroupMembership.isAdministrator(localUuid) && - nonPendingAdministratorCount == 1) + let isLastAdminInV2Group = (currentGroupMembership.isFullMemberAndAdministrator(localUuid) && + fullMemberAdministratorCount == 1) guard !isLastAdminInV2Group else { // This could happen if the last two admins leave at the same time // and race. @@ -588,14 +658,14 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } // Check that we are still invited or in group. - if currentGroupMembership.isPendingMember(localUuid) { + if currentGroupMembership.isPendingProfileKeyMember(localUuid) { // Decline invite let actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder() let localUserId = try groupV2Params.userId(forUuid: localUuid) actionBuilder.setDeletedUserID(localUserId) actionsBuilder.addDeletePendingMembers(try actionBuilder.build()) didChange = true - } else if currentGroupMembership.isNonPendingMember(localUuid) { + } else if currentGroupMembership.isFullMember(localUuid) { // Leave group let actionBuilder = GroupsProtoGroupChangeActionsDeleteMemberAction.builder() let localUserId = try groupV2Params.userId(forUuid: localUuid) diff --git a/SignalMessaging/groups/GroupsV2Changes.swift b/SignalMessaging/groups/GroupsV2Changes.swift index c5460ee125..f30a3b10ae 100644 --- a/SignalMessaging/groups/GroupsV2Changes.swift +++ b/SignalMessaging/groups/GroupsV2Changes.swift @@ -29,6 +29,7 @@ public struct ChangedGroupModel { // MARK: - +// TODO: Rename to GroupsV2IncomingChanges public class GroupsV2Changes { // GroupsV2Changes has one responsibility: applying incremental @@ -75,6 +76,7 @@ public class GroupsV2Changes { var newGroupName: String? = oldGroupModel.groupName var newAvatarData: Data? = oldGroupModel.groupAvatarData var newAvatarUrlPath = oldGroupModel.avatarUrlPath + var newInviteLinkPassword: Data? = oldGroupModel.inviteLinkPassword let oldGroupMembership = oldGroupModel.groupMembership var groupMembershipBuilder = oldGroupMembership.asBuilder @@ -82,13 +84,13 @@ public class GroupsV2Changes { let oldGroupAccess: GroupAccess = oldGroupModel.access var newMembersAccess = oldGroupAccess.members var newAttributesAccess = oldGroupAccess.attributes + var newAddFromInviteLinkAccess = oldGroupAccess.addFromInviteLink - if !oldGroupMembership.isPendingOrNonPendingMember(changeAuthorUuid) { + if !oldGroupMembership.isMemberOfAnyKind(changeAuthorUuid) { owsFailDebug("changeAuthorUuid not a full member of the group.") } - let isChangeAuthorMember = oldGroupMembership.isNonPendingMember(changeAuthorUuid) - let isChangeAuthorAdmin = (oldGroupMembership.isNonPendingMember(changeAuthorUuid) && - oldGroupMembership.isAdministrator(changeAuthorUuid)) + let isChangeAuthorMember = oldGroupMembership.isFullMember(changeAuthorUuid) + let isChangeAuthorAdmin = oldGroupMembership.isFullMemberAndAdministrator(changeAuthorUuid) let canAddMembers: Bool switch oldGroupAccess.members { case .unknown: @@ -100,6 +102,8 @@ public class GroupsV2Changes { case .any: // We no longer honor the "any" level. canAddMembers = false + case .unsatisfiable: + canAddMembers = false } let canRemoveMembers = isChangeAuthorAdmin let canModifyRoles = isChangeAuthorAdmin @@ -114,8 +118,11 @@ public class GroupsV2Changes { case .any: // We no longer honor the "any" level. canEditAttributes = false + case .unsatisfiable: + canEditAttributes = false } let canEditAccess = isChangeAuthorAdmin + let canEditInviteLinks = isChangeAuthorAdmin // This client can learn of profile keys from parsing group state protos. // After parsing, we should fill in profileKeys in the profile manager. @@ -146,12 +153,12 @@ public class GroupsV2Changes { // the service. This is one. let uuid = try groupV2Params.uuid(forUserId: userId) - guard !oldGroupMembership.isNonPendingMember(uuid) else { + guard !oldGroupMembership.isFullMember(uuid) else { throw OWSAssertionError("Invalid membership.") } groupMembershipBuilder.removeInvalidInvite(userId: userId) groupMembershipBuilder.remove(uuid) - groupMembershipBuilder.addNonPendingMember(uuid, role: role) + groupMembershipBuilder.addFullMember(uuid, role: role) guard let profileKeyCiphertextData = member.profileKey else { throw OWSAssertionError("Missing profileKeyCiphertext.") @@ -176,7 +183,7 @@ public class GroupsV2Changes { // Any member can leave the group. owsFailDebug("Cannot kick member.") } - if !oldGroupMembership.isNonPendingMember(uuid) { + if !oldGroupMembership.isFullMember(uuid) { owsFailDebug("Invalid membership.") } groupMembershipBuilder.removeInvalidInvite(userId: userId) @@ -206,14 +213,14 @@ public class GroupsV2Changes { // the service. This is one. let uuid = try groupV2Params.uuid(forUserId: userId) - guard oldGroupMembership.isNonPendingMember(uuid) else { + guard oldGroupMembership.isFullMember(uuid) else { throw OWSAssertionError("Invalid membership.") } if oldGroupMembership.role(for: uuid) == role { owsFailDebug("Member already has that role.") } groupMembershipBuilder.remove(uuid) - groupMembershipBuilder.addNonPendingMember(uuid, role: role) + groupMembershipBuilder.addFullMember(uuid, role: role) } for action in changeActionsProto.modifyMemberProfileKeys { @@ -224,7 +231,7 @@ public class GroupsV2Changes { let uuidCiphertext = try presentation.getUuidCiphertext() let uuid = try groupV2Params.uuid(forUuidCiphertext: uuidCiphertext) - guard oldGroupMembership.isNonPendingMember(uuid) else { + guard oldGroupMembership.isFullMember(uuid) else { throw OWSAssertionError("Invalid membership.") } @@ -284,12 +291,12 @@ public class GroupsV2Changes { } continue } - guard !oldGroupMembership.isPendingOrNonPendingMember(uuid) else { + guard !oldGroupMembership.isMemberOfAnyKind(uuid) else { throw OWSAssertionError("Invalid membership.") } groupMembershipBuilder.removeInvalidInvite(userId: userId) groupMembershipBuilder.remove(uuid) - groupMembershipBuilder.addPendingMember(uuid, role: role, addedByUuid: addedByUuid) + groupMembershipBuilder.addPendingProfileKeyMember(uuid, role: role, addedByUuid: addedByUuid) } for action in changeActionsProto.deletePendingMembers { @@ -309,7 +316,7 @@ public class GroupsV2Changes { } guard oldGroupMembership.hasInvalidInvite(forUserId: userId) || - oldGroupMembership.isPendingMember(uuid) else { + oldGroupMembership.isPendingProfileKeyMember(uuid) else { throw OWSAssertionError("Invalid membership.") } groupMembershipBuilder.removeInvalidInvite(userId: userId) @@ -336,10 +343,10 @@ public class GroupsV2Changes { let userId = uuidCiphertext.serialize().asData let uuid = try groupV2Params.uuid(forUuidCiphertext: uuidCiphertext) - guard oldGroupMembership.isPendingMember(uuid) else { + guard oldGroupMembership.isPendingProfileKeyMember(uuid) else { throw OWSAssertionError("Invalid membership.") } - guard !oldGroupMembership.isNonPendingMember(uuid) else { + guard !oldGroupMembership.isFullMember(uuid) else { throw OWSAssertionError("Invalid membership.") } guard let role = oldGroupMembership.role(for: uuid) else { @@ -347,7 +354,7 @@ public class GroupsV2Changes { } groupMembershipBuilder.removeInvalidInvite(userId: userId) groupMembershipBuilder.remove(uuid) - groupMembershipBuilder.addNonPendingMember(uuid, role: role) + groupMembershipBuilder.addFullMember(uuid, role: role) if uuid != changeAuthorUuid { // Only the invitee can accept an invitation. @@ -410,7 +417,7 @@ public class GroupsV2Changes { guard let protoAccess = action.attributesAccess else { throw OWSAssertionError("Missing access.") } - newAttributesAccess = GroupAccess.groupV2Access(forProtoAccess: protoAccess) + newAttributesAccess = GroupV2Access.access(forProtoAccess: protoAccess) if newAttributesAccess == .unknown { owsFailDebug("Unknown attributes access.") @@ -425,15 +432,99 @@ public class GroupsV2Changes { guard let protoAccess = action.membersAccess else { throw OWSAssertionError("Missing access.") } - newMembersAccess = GroupAccess.groupV2Access(forProtoAccess: protoAccess) + newMembersAccess = GroupV2Access.access(forProtoAccess: protoAccess) if newMembersAccess == .unknown { owsFailDebug("Unknown member access.") } } + if let action = changeActionsProto.modifyAddFromInviteLinkAccess { + if !canEditInviteLinks { + owsFailDebug("Cannot edit addFromInviteLink access.") + } + + guard let protoAccess = action.addFromInviteLinkAccess else { + throw OWSAssertionError("Missing access.") + } + newAddFromInviteLinkAccess = GroupV2Access.access(forProtoAccess: protoAccess) + + if newAddFromInviteLinkAccess == .unknown { + owsFailDebug("Unknown addFromInviteLink access.") + } + } + + for action in changeActionsProto.addRequestingMembers { + if !canAddMembers { + owsFailDebug("Cannot add members.") + } + + guard let requestingMember = action.added else { + throw OWSAssertionError("Missing requestingMember.") + } + // TODO: Modify this parsing to reflect action contents. + guard let userId = requestingMember.userID else { + throw OWSAssertionError("Missing userID.") + } + // Some userIds/uuidCiphertexts can be validated by + // the service. This is one. + // + // TODO: Is this one? + let uuid = try groupV2Params.uuid(forUserId: userId) + + guard let profileKeyCiphertextData = requestingMember.profileKey else { + throw OWSAssertionError("Missing profileKeyCiphertext.") + } + let profileKeyCiphertext = try ProfileKeyCiphertext(contents: [UInt8](profileKeyCiphertextData)) + let profileKey = try groupV2Params.profileKey(forProfileKeyCiphertext: profileKeyCiphertext, + uuid: uuid) + + guard let presentationData = requestingMember.presentation else { + throw OWSAssertionError("Missing presentation.") + } + let presentation = try ProfileKeyCredentialPresentation(contents: [UInt8](presentationData)) + let uuidCiphertext = try presentation.getUuidCiphertext() + let uuidFromPresentation = try groupV2Params.uuid(forUuidCiphertext: uuidCiphertext) + // TODO: + guard uuid == uuidFromPresentation else { + throw OWSAssertionError("uuids do not match.") + } + + guard oldGroupMembership.isMemberOfAnyKind(uuid) else { + throw OWSAssertionError("Invalid membership.") + } + + let profileKeyCiphertextFromPresentation = try presentation.getProfileKeyCiphertext() + let profileKeyFromPresentation = try groupV2Params.profileKey(forProfileKeyCiphertext: profileKeyCiphertextFromPresentation, + uuid: uuid) + // TODO: + guard profileKey == profileKeyFromPresentation else { + throw OWSAssertionError("profileKeys do not match.") + } + + guard !oldGroupMembership.isMemberOfAnyKind(uuid) else { + throw OWSAssertionError("Invalid membership.") + } + groupMembershipBuilder.removeInvalidInvite(userId: userId) + groupMembershipBuilder.remove(uuid) + groupMembershipBuilder.addRequestingMember(uuid) + + profileKeys[uuid] = profileKey + } + + if let action = changeActionsProto.modifyInviteLinkPassword { + if !canEditInviteLinks { + owsFailDebug("Cannot modify inviteLinkPassword.") + } + + // Change clears or updates the group inviteLinkPassword. + newInviteLinkPassword = action.inviteLinkPassword + } + let newGroupMembership = groupMembershipBuilder.build() - let newGroupAccess = GroupAccess(members: newMembersAccess, attributes: newAttributesAccess) + let newGroupAccess = GroupAccess(members: newMembersAccess, attributes: newAttributesAccess, addFromInviteLink: newAddFromInviteLinkAccess) + + GroupsV2Protos.validateInviteLinkState(inviteLinkPassword: newInviteLinkPassword, groupAccess: newGroupAccess) var builder = oldGroupModel.asBuilder builder.name = newGroupName @@ -442,6 +533,7 @@ public class GroupsV2Changes { builder.groupAccess = newGroupAccess builder.groupV2Revision = newRevision builder.avatarUrlPath = newAvatarUrlPath + builder.inviteLinkPassword = newInviteLinkPassword let newGroupModel = try builder.buildAsV2(transaction: transaction) return ChangedGroupModel(oldGroupModel: oldGroupModel, diff --git a/SignalMessaging/groups/GroupsV2ProfileKeyUpdater.swift b/SignalMessaging/groups/GroupsV2ProfileKeyUpdater.swift index 8ecfc9483b..f606e87e2d 100644 --- a/SignalMessaging/groups/GroupsV2ProfileKeyUpdater.swift +++ b/SignalMessaging/groups/GroupsV2ProfileKeyUpdater.swift @@ -118,7 +118,7 @@ class GroupsV2ProfileKeyUpdater { let groupMembership = groupThread.groupModel.groupMembership // We only need to update v2 groups of which we are a full member. guard groupThread.isGroupV2Thread, - groupMembership.isNonPendingMember(localAddress) else { + groupMembership.isFullMember(localAddress) else { return } let groupId = groupThread.groupModel.groupId @@ -254,7 +254,7 @@ class GroupsV2ProfileKeyUpdater { } return self.groupsV2.fetchCurrentGroupV2Snapshot(groupModel: groupModel) }.map(on: .global()) { (groupV2Snapshot: GroupV2Snapshot) throws -> TSGroupThread in - guard groupV2Snapshot.groupMembership.isNonPendingMember(localAddress) else { + guard groupV2Snapshot.groupMembership.isFullMember(localAddress) else { // We're not a full member, no need to update profile key. throw GroupsV2Error.redundantChange } diff --git a/SignalMessaging/groups/GroupsV2Protos.swift b/SignalMessaging/groups/GroupsV2Protos.swift index 0e1ddebffe..c75097a5d1 100644 --- a/SignalMessaging/groups/GroupsV2Protos.swift +++ b/SignalMessaging/groups/GroupsV2Protos.swift @@ -101,16 +101,20 @@ public class GroupsV2Protos { groupBuilder.setAvatar(avatarUrl) } - groupBuilder.setAccessControl(try buildAccessProto(groupAccess: groupModel.access)) + let groupAccess = groupModel.access + groupBuilder.setAccessControl(try buildAccessProto(groupAccess: groupAccess)) + + if let inviteLinkPassword = groupModel.inviteLinkPassword, + !inviteLinkPassword.isEmpty { + groupBuilder.setInviteLinkPassword(inviteLinkPassword) + } // * You will be member 0 and the only admin. // * Other members will be non-admin members. // // Add local user first to ensure that they are user 0. let groupMembership = groupModel.groupMembership - let localAddress = SignalServiceAddress(uuid: localUuid) - assert(groupMembership.isAdministrator(localAddress)) - assert(!groupMembership.isPendingMember(localAddress)) + assert(groupMembership.isFullMemberAndAdministrator(localUuid)) groupBuilder.addMembers(try buildMemberProto(profileKeyCredential: localProfileKeyCredential, role: .administrator, groupV2Params: groupV2Params)) @@ -118,25 +122,24 @@ public class GroupsV2Protos { guard uuid != localUuid else { continue } - let address = SignalServiceAddress(uuid: uuid) - let isAdministrator = groupMembership.isAdministrator(address) - let isPending = groupMembership.isPendingMember(address) - let role: GroupsProtoMemberRole = isAdministrator ? .administrator : .`default` - guard !isPending else { + let isInvited = groupMembership.isPendingProfileKeyMember(uuid) + guard !isInvited else { continue } + let isAdministrator = groupMembership.isFullMemberAndAdministrator(uuid) + let role: GroupsProtoMemberRole = isAdministrator ? .administrator : .`default` groupBuilder.addMembers(try buildMemberProto(profileKeyCredential: profileKeyCredential, role: role, groupV2Params: groupV2Params)) } - for address in groupMembership.pendingMembers { + for address in groupMembership.pendingProfileKeyMembers { guard let uuid = address.uuid else { throw OWSAssertionError("Missing uuid.") } guard uuid != localUuid else { continue } - let isAdministrator = groupMembership.isAdministrator(address) + let isAdministrator = groupMembership.isFullOrInvitedAdministrator(uuid) let role: GroupsProtoMemberRole = isAdministrator ? .administrator : .`default` groupBuilder.addPendingMembers(try buildPendingMemberProto(uuid: uuid, role: role, @@ -144,13 +147,26 @@ public class GroupsV2Protos { groupV2Params: groupV2Params)) } + validateInviteLinkState(inviteLinkPassword: groupModel.inviteLinkPassword, groupAccess: groupAccess) + return try groupBuilder.build() } + public class func validateInviteLinkState(inviteLinkPassword: Data?, groupAccess: GroupAccess) { + let canJoinFromInviteLink = groupAccess.addFromInviteLink != .unsatisfiable + let hasInviteLinkPassword = inviteLinkPassword?.count ?? 0 > 0 + if canJoinFromInviteLink, !hasInviteLinkPassword { + owsFailDebug("Invite links enabled without inviteLinkPassword.") + } else if !canJoinFromInviteLink, hasInviteLinkPassword { + owsFailDebug("inviteLinkPassword set but invite links not enabled.") + } + } + public class func buildAccessProto(groupAccess: GroupAccess) throws -> GroupsProtoAccessControl { let builder = GroupsProtoAccessControl.builder() - builder.setAttributes(GroupAccess.protoAccess(forGroupV2Access: groupAccess.attributes)) - builder.setMembers(GroupAccess.protoAccess(forGroupV2Access: groupAccess.members)) + builder.setAttributes(groupAccess.attributes.protoAccess) + builder.setMembers(groupAccess.members.protoAccess) + builder.setAddFromInviteLink(groupAccess.addFromInviteLink.protoAccess) return try builder.build() } @@ -316,6 +332,8 @@ public class GroupsV2Protos { pendingMembers.append(pendingMember) } + let inviteLinkPassword = groupProto.inviteLinkPassword + guard let accessControl = groupProto.accessControl else { throw OWSAssertionError("Missing accessControl.") } @@ -325,11 +343,20 @@ public class GroupsV2Protos { guard let accessControlForMembers = accessControl.members else { throw OWSAssertionError("Missing accessControl.members.") } + // If group state does not have "invite link" access specified, + // assume invite links are disabled. + let accessControlForAddFromInviteLink = accessControl.addFromInviteLink ?? .unsatisfiable // If the timer blob is not populated or has zero duration, // disappearing messages should be disabled. let disappearingMessageToken = groupV2Params.decryptDisappearingMessagesTimer(groupProto.disappearingMessagesTimer) + let groupAccess = GroupAccess(members: GroupV2Access.access(forProtoAccess: accessControlForMembers), + attributes: GroupV2Access.access(forProtoAccess: accessControlForAttributes), + addFromInviteLink: GroupV2Access.access(forProtoAccess: accessControlForAddFromInviteLink)) + + validateInviteLinkState(inviteLinkPassword: inviteLinkPassword, groupAccess: groupAccess) + let revision = groupProto.revision let groupSecretParamsData = groupV2Params.groupSecretParamsData return GroupV2SnapshotImpl(groupSecretParamsData: groupSecretParamsData, @@ -341,8 +368,8 @@ public class GroupsV2Protos { members: members, pendingMembers: pendingMembers, invalidInvites: invalidInvites, - accessControlForAttributes: accessControlForAttributes, - accessControlForMembers: accessControlForMembers, + groupAccess: groupAccess, + inviteLinkPassword: inviteLinkPassword, disappearingMessageToken: disappearingMessageToken, profileKeys: profileKeys) } diff --git a/SignalMessaging/utils/GroupManager+SignalMessaging.swift b/SignalMessaging/utils/GroupManager+SignalMessaging.swift index 0f6082e164..9bcc28073c 100644 --- a/SignalMessaging/utils/GroupManager+SignalMessaging.swift +++ b/SignalMessaging/utils/GroupManager+SignalMessaging.swift @@ -21,7 +21,7 @@ public extension GroupManager { replacementAdminUuid: UUID? = nil, success: (() -> Void)?) { - guard groupThread.isLocalUserPendingOrNonPendingMember else { + guard groupThread.isLocalUserMemberOfAnyKind else { owsFailDebug("unexpectedly trying to leave group for which we're not a member.") return } diff --git a/SignalServiceKit/src/Contacts/Threads/TSGroupThread+OWS.swift b/SignalServiceKit/src/Contacts/Threads/TSGroupThread+OWS.swift index 76f305ebaa..9394139ec3 100644 --- a/SignalServiceKit/src/Contacts/Threads/TSGroupThread+OWS.swift +++ b/SignalServiceKit/src/Contacts/Threads/TSGroupThread+OWS.swift @@ -6,24 +6,39 @@ import Foundation @objc public extension TSGroupThread { - var isLocalUserInGroup: Bool { + var isLocalUserMemberOfAnyKind: Bool { guard let localAddress = TSAccountManager.localAddress else { return false } - return groupModel.groupMembership.isNonPendingMember(localAddress) + return groupModel.groupMembership.isMemberOfAnyKind(localAddress) } - var isLocalUserPendingOrNonPendingMember: Bool { + var isLocalUserFullMemberOfGroup: Bool { guard let localAddress = TSAccountManager.localAddress else { return false } - return groupModel.groupMembership.isPendingOrNonPendingMember(localAddress) + return groupModel.groupMembership.isFullMember(localAddress) } - var isLocalUserPendingMember: Bool { + var isLocalUserPendingProfileKeyMember: Bool { guard let localAddress = TSAccountManager.localAddress else { return false } - return groupModel.groupMembership.isPendingMember(localAddress) + return groupModel.groupMembership.isPendingProfileKeyMember(localAddress) + } + + var isLocalUserRequestingMember: Bool { + guard let localAddress = TSAccountManager.localAddress else { + return false + } + return groupModel.groupMembership.isRequestingMember(localAddress) + } + + var isLocalUserFullOrInvitedMemberOfGroup: Bool { + guard let localAddress = TSAccountManager.localAddress else { + return false + } + return (groupModel.groupMembership.isFullMember(localAddress) || + groupModel.groupMembership.isPendingProfileKeyMember(localAddress)) } } diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift index 47793fdbf9..b33e02db46 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift @@ -346,6 +346,10 @@ extension GroupUpdateCopy { case .administrator: return NSLocalizedString("GROUP_ACCESS_LEVEL_ADMINISTRATORS", comment: "Description of the 'admins only' access level.") + case .unsatisfiable: + // TODO: + return NSLocalizedString("GROUP_ACCESS_LEVEL_UNSATISFIABLE", + comment: "Description of the 'unsatisfiable' access level.") } } @@ -415,7 +419,7 @@ extension GroupUpdateCopy { mutating func addMembershipUpdates(oldGroupMembership: GroupMembership) { var membershipCounts = MembershipCounts() - let allUsersUnsorted = oldGroupMembership.allUsers.union(newGroupMembership.allUsers) + let allUsersUnsorted = oldGroupMembership.allMembersOfAnyKind.union(newGroupMembership.allMembersOfAnyKind) var allUsersSorted = allUsersUnsorted.sorted { (left, right) -> Bool in // Use an arbitrary sort to ensure the output is deterministic. return left.stringForDisplay > right.stringForDisplay @@ -443,6 +447,9 @@ extension GroupUpdateCopy { addMemberRoleUpdates(for: address, oldGroupMembership: oldGroupMembership) case .invited: addUserLeftOrWasKickedOutOfGroupThenWasInvitedToTheGroup(for: address) + case .requesting: + owsFailDebug("This transition is unexpected") + addUserRequestedToJoinGroup(for: address) case .none: addUserLeftOrWasKickedOutOfGroup(for: address) } @@ -468,11 +475,26 @@ extension GroupUpdateCopy { case .invited: // Membership status didn't change. break + case .requesting: + addUserRequestedToJoinGroup(for: address) case .none: addUserInviteWasDeclinedOrRevoked(for: address, oldGroupMembership: oldGroupMembership, membershipCounts: &membershipCounts) } + case .requesting: + switch newMembershipStatus { + case .normalMember: + addUserRequestWasApproved(for: address) + case .invited: + addUserWasInvitedToTheGroup(for: address, + membershipCounts: &membershipCounts) + case .requesting: + // Membership status didn't change. + break + case .none: + addUserRequestWasRejected(for: address) + } case .none: switch newMembershipStatus { case .normalMember: @@ -480,6 +502,8 @@ extension GroupUpdateCopy { case .invited: addUserWasInvitedToTheGroup(for: address, membershipCounts: &membershipCounts) + case .requesting: + addUserRequestedToJoinGroup(for: address) case .none: // Membership status didn't change. break @@ -579,8 +603,8 @@ extension GroupUpdateCopy { mutating func addMemberRoleUpdates(for address: SignalServiceAddress, oldGroupMembership: GroupMembership) { - let oldIsAdministrator = oldGroupMembership.isAdministrator(address) - let newIsAdministrator = newGroupMembership.isAdministrator(address) + let oldIsAdministrator = oldGroupMembership.isFullMemberAndAdministrator(address) + let newIsAdministrator = newGroupMembership.isFullMemberAndAdministrator(address) guard oldIsAdministrator != newIsAdministrator else { // Role didn't change. @@ -826,7 +850,7 @@ extension GroupUpdateCopy { var inviterName: String? var inviterAddress: SignalServiceAddress? - if let inviterUuid = oldGroupMembership.addedByUuid(forPendingMember: address) { + if let inviterUuid = oldGroupMembership.addedByUuid(forPendingProfileKeyMember: address) { inviterAddress = SignalServiceAddress(uuid: inviterUuid) inviterName = contactsManager.displayName(for: SignalServiceAddress(uuid: inviterUuid), transaction: transaction) @@ -924,7 +948,7 @@ extension GroupUpdateCopy { var inviterName: String? var inviterAddress: SignalServiceAddress? - if let inviterUuid = oldGroupMembership.addedByUuid(forPendingMember: address) { + if let inviterUuid = oldGroupMembership.addedByUuid(forPendingProfileKeyMember: address) { inviterAddress = SignalServiceAddress(uuid: inviterUuid) inviterName = contactsManager.displayName(for: SignalServiceAddress(uuid: inviterUuid), transaction: transaction) @@ -1198,6 +1222,77 @@ extension GroupUpdateCopy { } } + // MARK: - Requesting Members + + mutating func addUserRequestedToJoinGroup(for address: SignalServiceAddress) { + let isLocalUser = localAddress == address + if isLocalUser { + addItem(.userMembershipState, + address: address, + copy: NSLocalizedString("GROUP_LOCAL_USER_REQUESTED_TO_JOIN_TO_THE_GROUP", + comment: "Message indicating that the local user requested to join the group.")) + } else { + let requesterName = contactsManager.displayName(for: address, transaction: transaction) + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUESTED_TO_JOIN_THE_GROUP_FORMAT", + comment: "Message indicating that a remote user requested to join the group. Embeds {{requesting user name}}.") + addItem(.userMembershipState, address: address, format: format, requesterName) + } + } + + mutating func addUserRequestWasApproved(for address: SignalServiceAddress) { + let isLocalUser = localAddress == address + if isLocalUser { + // TODO: Should we embed the name of the user who approved us? + addItem(.userMembershipState, + address: address, + copy: NSLocalizedString("GROUP_LOCAL_USER_REQUEST_APPROVED", + comment: "Message indicating that the local user's request to join the group was approved.")) + } else { + let requesterName = contactsManager.displayName(for: address, transaction: transaction) + switch updater { + case .localUser: + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUEST_APPROVED_BY_LOCAL_USER_FORMAT", + comment: "Message indicating that a remote user's request to join the group was approved by the local user. Embeds {{requesting user name}}.") + addItem(.userMembershipState, address: address, format: format, requesterName) + case .otherUser(let updaterName, _): + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUEST_APPROVED_BY_REMOTE_USER_FORMAT", + comment: "Message indicating that a remote user's request to join the group was approved by another user. Embeds {{ %1$@ requesting user name, %2$@ approving user name }}.") + addItem(.userMembershipState, address: address, format: format, requesterName, updaterName) + case .unknown: + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUEST_APPROVED_FORMAT", + comment: "Message indicating that a remote user's request to join the group was approved. Embeds {{requesting user name}}.") + addItem(.userMembershipState, address: address, format: format, requesterName) + } + } + } + + mutating func addUserRequestWasRejected(for address: SignalServiceAddress) { + let isLocalUser = localAddress == address + if isLocalUser { + // TODO: Should we embed the name of the user who rejected us? + addItem(.userMembershipState, + address: address, + copy: NSLocalizedString("GROUP_LOCAL_USER_REQUEST_REJECTED", + comment: "Message indicating that the local user's request to join the group was rejected.")) + } else { + let requesterName = contactsManager.displayName(for: address, transaction: transaction) + switch updater { + case .localUser: + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUEST_REJECTED_BY_LOCAL_USER_FORMAT", + comment: "Message indicating that a remote user's request to join the group was rejected by the local user. Embeds {{requesting user name}}.") + addItem(.userMembershipState, address: address, format: format, requesterName) + case .otherUser(let updaterName, _): + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUEST_REJECTED_BY_REMOTE_USER_FORMAT", + comment: "Message indicating that a remote user's request to join the group was rejected by another user. Embeds {{ %1$@ requesting user name, %2$@ approving user name }}.") + addItem(.userMembershipState, address: address, format: format, requesterName, updaterName) + case .unknown: + let format = NSLocalizedString("GROUP_REMOTE_USER_REQUEST_REJECTED_FORMAT", + comment: "Message indicating that a remote user's request to join the group was rejected. Embeds {{requesting user name}}.") + addItem(.userMembershipState, address: address, format: format, requesterName) + } + } + } + // MARK: - Disappearing Messages mutating func addDisappearingMessageUpdates(oldToken: DisappearingMessageToken?, @@ -1311,7 +1406,7 @@ extension GroupUpdateCopy { comment: "Message indicating that the local user has joined the group.")) case .invited: if let localAddress = Self.tsAccountManager.localAddress, - let inviterUuid = newGroupMembership.addedByUuid(forPendingMember: localAddress) { + let inviterUuid = newGroupMembership.addedByUuid(forPendingProfileKeyMember: localAddress) { let inviterAddress = SignalServiceAddress(uuid: inviterUuid) let inviterName = contactsManager.displayName(for: inviterAddress, transaction: transaction) let format = NSLocalizedString("GROUP_LOCAL_USER_INVITED_BY_REMOTE_USER_FORMAT", @@ -1325,6 +1420,12 @@ extension GroupUpdateCopy { copy: NSLocalizedString("GROUP_LOCAL_USER_INVITED_TO_THE_GROUP", comment: "Message indicating that the local user was invited to the group.")) } + case .requesting: + if let localAddress = Self.tsAccountManager.localAddress { + addUserRequestedToJoinGroup(for: localAddress) + } else { + owsFailDebug("Missing localAddress.") + } case .none: if !DebugFlags.permissiveGroupUpdateInfoMessages { owsFailDebug("Learned of group without any membership status.") @@ -1336,9 +1437,10 @@ extension GroupUpdateCopy { // MARK: - Membership Status - enum MembershipStatus { + fileprivate enum MembershipStatus { case normalMember case invited + case requesting case none } @@ -1348,10 +1450,12 @@ extension GroupUpdateCopy { func membershipStatus(of address: SignalServiceAddress, in groupMembership: GroupMembership) -> MembershipStatus { - if groupMembership.isNonPendingMember(address) { + if groupMembership.isFullMember(address) { return .normalMember - } else if groupMembership.isPendingMember(address) { + } else if groupMembership.isPendingProfileKeyMember(address) { return .invited + } else if groupMembership.isRequestingMember(address) { + return .requesting } else { return .none } diff --git a/SignalServiceKit/src/Messages/OWSBlockingManager.m b/SignalServiceKit/src/Messages/OWSBlockingManager.m index 91dc22cb12..05c54c469a 100644 --- a/SignalServiceKit/src/Messages/OWSBlockingManager.m +++ b/SignalServiceKit/src/Messages/OWSBlockingManager.m @@ -486,7 +486,8 @@ NSString *const kOWSBlockingManager_SyncedBlockedGroupIdsKey = @"kOWSBlockingMan TSGroupThread *_Nullable groupThread = [TSGroupThread fetchWithGroupId:groupId transaction:transaction]; // Quit the group if we're a member - if (blockMode == BlockMode_LocalShouldLeaveGroups && groupThread.isLocalUserPendingOrNonPendingMember) { + BOOL isInGroup = groupThread.isLocalUserMemberOfAnyKind; + if (blockMode == BlockMode_LocalShouldLeaveGroups && isInGroup) { [GroupManager leaveGroupOrDeclineInviteAsyncWithoutUIWithGroupThread:groupThread transaction:transaction success:nil]; diff --git a/SignalServiceKit/src/Messages/OWSMessageManager.m b/SignalServiceKit/src/Messages/OWSMessageManager.m index 80af97cd36..80476a50c0 100644 --- a/SignalServiceKit/src/Messages/OWSMessageManager.m +++ b/SignalServiceKit/src/Messages/OWSMessageManager.m @@ -719,7 +719,7 @@ NS_ASSUME_NONNULL_BEGIN return nil; } if (groupThread) { - if (!groupThread.isLocalUserInGroup) { + if (!groupThread.isLocalUserFullMemberOfGroup) { OWSLogInfo(@"Ignoring messages for left group."); return nil; } @@ -806,21 +806,16 @@ NS_ASSUME_NONNULL_BEGIN return nil; } - if (!groupThread.isLocalUserInGroup) { - // We don't want to process messages for groups in which we are a pending member. - OWSLogInfo(@"Ignoring messages for left group."); - return nil; - } if (!envelope.sourceAddress) { OWSFailDebug(@"Missing sender address."); return nil; } - if (!groupThread.isLocalUserInGroup) { + if (!groupThread.isLocalUserFullMemberOfGroup) { // We don't want to process messages for groups in which we are a pending member. OWSLogInfo(@"Ignoring messages for left group."); return nil; } - if (![groupModel.groupMembership isNonPendingMember:envelope.sourceAddress]) { + if (![groupModel.groupMembership isFullMember:envelope.sourceAddress]) { // We don't want to process group messages for non-members. OWSLogInfo(@"Ignoring messages for user not in group: %@.", envelope.sourceAddress); return nil; @@ -1138,7 +1133,7 @@ NS_ASSUME_NONNULL_BEGIN if (typingMessage.hasGroupID) { TSGroupThread *_Nullable groupThread = [TSGroupThread fetchWithGroupId:typingMessage.groupID transaction:transaction]; - if (groupThread != nil && !groupThread.isLocalUserInGroup) { + if (groupThread != nil && !groupThread.isLocalUserFullOrInvitedMemberOfGroup) { OWSLogInfo(@"Ignoring messages for left group."); return; } @@ -1243,7 +1238,7 @@ NS_ASSUME_NONNULL_BEGIN OWSFailDebug(@"Group update for invalid group version."); return; } - if (oldGroupThread.isLocalUserInGroup) { + if (oldGroupThread.isLocalUserFullMemberOfGroup) { // If the local user had left the group we couldn't trust our local group state - we'd // have to trust the remote membership. // @@ -1952,7 +1947,7 @@ NS_ASSUME_NONNULL_BEGIN } // Ensure we are in the group. - if (!gThread.isLocalUserInGroup) { + if (!gThread.isLocalUserFullOrInvitedMemberOfGroup) { OWSLogWarn(@"Ignoring 'Group Info Request' message for group we no longer belong to."); return; } diff --git a/SignalServiceKit/src/Messages/OWSMessageSender.swift b/SignalServiceKit/src/Messages/OWSMessageSender.swift index 8e406700a9..37fcedb38e 100644 --- a/SignalServiceKit/src/Messages/OWSMessageSender.swift +++ b/SignalServiceKit/src/Messages/OWSMessageSender.swift @@ -629,13 +629,14 @@ extension MessageSending { // If a member has left the group since this message was enqueued, // they should not receive the message. let groupMembership = groupThread.groupModel.groupMembership - var currentValidRecipients = groupMembership.nonPendingMembers + var currentValidRecipients = groupMembership.fullMembers + // ...or latest known list of "additional recipients". // // This is used to send group update messages for v2 groups to // pending members who are not included in .sendingRecipientAddresses(). if GroupManager.shouldMessageHaveAdditionalRecipients(message, groupThread: groupThread) { - currentValidRecipients.formUnion(groupMembership.pendingMembers) + currentValidRecipients.formUnion(groupMembership.pendingProfileKeyOrRequestMembers) } currentValidRecipients.remove(localAddress) recipientAddresses.formIntersection(currentValidRecipients) diff --git a/SignalServiceKit/src/Network/Receiving/GroupsV2MessageProcessor.swift b/SignalServiceKit/src/Network/Receiving/GroupsV2MessageProcessor.swift index e7afeee030..be0b1b916d 100644 --- a/SignalServiceKit/src/Network/Receiving/GroupsV2MessageProcessor.swift +++ b/SignalServiceKit/src/Network/Receiving/GroupsV2MessageProcessor.swift @@ -345,14 +345,14 @@ class IncomingGroupsV2MessageQueue: NSObject, MessageProcessingPipelineStage { owsFailDebug("Missing thread.") return true } - guard groupThread.groupModel.groupMembership.isNonPendingMember(localAddress) else { + guard groupThread.groupModel.groupMembership.isFullMember(localAddress) else { // * Local user might have just left the group. // * Local user may have just learned that we were removed from the group. // * Local user might be a pending member with an invite. Logger.warn("Discarding envelope; local user is not an active group member.") return true } - guard groupThread.groupModel.groupMembership.isNonPendingMember(sourceAddress) else { + guard groupThread.groupModel.groupMembership.isFullMember(sourceAddress) else { // * The sender might have just left the group. Logger.warn("Discarding envelope; sender is not an active group member.") return true diff --git a/SignalServiceKit/src/Storage/Database/Records/ThreadFinder.swift b/SignalServiceKit/src/Storage/Database/Records/ThreadFinder.swift index fea89f6f2e..3a1ca677e7 100644 --- a/SignalServiceKit/src/Storage/Database/Records/ThreadFinder.swift +++ b/SignalServiceKit/src/Storage/Database/Records/ThreadFinder.swift @@ -162,6 +162,14 @@ struct YAPDBThreadFinder: ThreadFinder { @objc public class GRDBThreadFinder: NSObject, ThreadFinder { + // MARK: - Dependencies + + private class var tsAccountManager: TSAccountManager { + return .sharedInstance() + } + + // MARK: - + public typealias ReadTransaction = GRDBReadTransaction static let cn = ThreadRecord.columnName @@ -271,9 +279,10 @@ public class GRDBThreadFinder: NSObject, ThreadFinder { @objc public class func hasPendingMessageRequest(thread: TSThread, transaction: GRDBReadTransaction) -> Bool { + // TODO: Should we consult isRequestingMember() here? if let groupThread = thread as? TSGroupThread, groupThread.isGroupV2Thread, - groupThread.isLocalUserPendingMember { + groupThread.isLocalUserPendingProfileKeyMember { return true } @@ -285,7 +294,7 @@ public class GRDBThreadFinder: NSObject, ThreadFinder { if OWSBlockingManager.shared().isThreadBlocked(thread) { return true } let isGroupThread = thread is TSGroupThread - let isLocalUserInGroup = (thread as? TSGroupThread)?.isLocalUserInGroup == true + let isLocalUserInGroup = (thread as? TSGroupThread)?.isLocalUserFullOrInvitedMemberOfGroup == true // If this is a group thread and we're not a member, never show the message request. if isGroupThread, !isLocalUserInGroup { return false } diff --git a/SignalServiceKit/src/groups/GroupAccess.swift b/SignalServiceKit/src/groups/GroupAccess.swift index 3854ba98cd..f524565bac 100644 --- a/SignalServiceKit/src/groups/GroupAccess.swift +++ b/SignalServiceKit/src/groups/GroupAccess.swift @@ -10,6 +10,7 @@ public enum GroupV2Access: UInt, Codable { case any case member case administrator + case unsatisfiable var description: String { get { @@ -22,9 +23,39 @@ public enum GroupV2Access: UInt, Codable { return "member" case .administrator: return "administrator" + case .unsatisfiable: + return "unsatisfiable" } } } + + public static func access(forProtoAccess value: GroupsProtoAccessControlAccessRequired) -> GroupV2Access { + switch value { + case .member: + return .member + case .administrator: + return .administrator + case .unsatisfiable: + return .unsatisfiable + default: + return .unknown + } + } + + public var protoAccess: GroupsProtoAccessControlAccessRequired { + switch self { + case .any: + return .unknown + case .member: + return .member + case .administrator: + return .administrator + case .unsatisfiable: + return .unsatisfiable + default: + return .unknown + } + } } // MARK: - @@ -36,11 +67,15 @@ public class GroupAccess: MTLModel { public var members: GroupV2Access = .unknown @objc public var attributes: GroupV2Access = .unknown + @objc + public var addFromInviteLink: GroupV2Access = .unknown public init(members: GroupV2Access, - attributes: GroupV2Access) { + attributes: GroupV2Access, + addFromInviteLink: GroupV2Access) { self.members = members self.attributes = attributes + self.addFromInviteLink = addFromInviteLink super.init() } @@ -62,12 +97,12 @@ public class GroupAccess: MTLModel { @objc public static var allAccess: GroupAccess { - return GroupAccess(members: .any, attributes: .any) + return GroupAccess(members: .any, attributes: .any, addFromInviteLink: .any) } @objc public static var adminOnly: GroupAccess { - return GroupAccess(members: .administrator, attributes: .administrator) + return GroupAccess(members: .administrator, attributes: .administrator, addFromInviteLink: .administrator) } @objc @@ -77,34 +112,10 @@ public class GroupAccess: MTLModel { @objc public static var defaultForV2: GroupAccess { - return GroupAccess(members: .member, attributes: .member) - } - - public class func groupV2Access(forProtoAccess value: GroupsProtoAccessControlAccessRequired) -> GroupV2Access { - switch value { - case .member: - return .member - case .administrator: - return .administrator - default: - return .unknown - } - } - - public class func protoAccess(forGroupV2Access value: GroupV2Access) -> GroupsProtoAccessControlAccessRequired { - switch value { - case .any: - return .unknown - case .member: - return .member - case .administrator: - return .administrator - default: - return .unknown - } + return GroupAccess(members: .member, attributes: .member, addFromInviteLink: .unsatisfiable) } public override var debugDescription: String { - return "[members: \(members.description), attributes: \(attributes.description), ]" + return "[members: \(members), attributes: \(attributes), addFromInviteLink: \(addFromInviteLink), ]" } } diff --git a/SignalServiceKit/src/groups/GroupManager.swift b/SignalServiceKit/src/groups/GroupManager.swift index c97e26180c..86a23f27bf 100644 --- a/SignalServiceKit/src/groups/GroupManager.swift +++ b/SignalServiceKit/src/groups/GroupManager.swift @@ -246,9 +246,9 @@ public class GroupManager: NSObject { // The group creator is an administrator; // the other members are normal users. var builder = GroupMembership.Builder() - builder.addNonPendingMembers(Set(membersParam), role: .normal) + builder.addFullMembers(Set(membersParam), role: .normal) builder.remove(localAddress) - builder.addNonPendingMember(localAddress, role: .administrator) + builder.addFullMember(localAddress, role: .administrator) return builder.build() }.then(on: .global()) { (groupMembership: GroupMembership) -> Promise in // If we might create a v2 group, @@ -258,7 +258,7 @@ public class GroupManager: NSObject { return Promise.value(groupMembership) } return firstly { () -> Promise in - self.groupsV2.tryToEnsureProfileKeyCredentials(for: Array(groupMembership.allUsers)) + self.groupsV2.tryToEnsureProfileKeyCredentials(for: Array(groupMembership.allMembersOfAnyKind)) }.map(on: .global()) { (_) -> GroupMembership in return groupMembership } @@ -268,11 +268,11 @@ public class GroupManager: NSObject { // Before we create a v2 group, we need to separate out the // pending and non-pending members. If we already know we're // going to create a v1 group, we shouldn't separate them. - let groupMembership = self.separatePendingMembers(in: proposedGroupMembership, - oldGroupModel: nil, - transaction: transaction) + let groupMembership = self.separatePendingProfileKeyMembers(in: proposedGroupMembership, + oldGroupModel: nil, + transaction: transaction) - guard groupMembership.nonPendingMembers.contains(localAddress) else { + guard groupMembership.isFullMember(localAddress) else { throw OWSAssertionError("Missing localAddress.") } @@ -379,9 +379,9 @@ public class GroupManager: NSObject { // * We have a profile key credential for them. // * Their account has the "groups v2" capability // (e.g. all of their clients support groups v2. - private static func separatePendingMembers(in newGroupMembership: GroupMembership, - oldGroupModel: TSGroupModel?, - transaction: SDSAnyReadTransaction) -> GroupMembership { + private static func separatePendingProfileKeyMembers(in newGroupMembership: GroupMembership, + oldGroupModel: TSGroupModel?, + transaction: SDSAnyReadTransaction) -> GroupMembership { guard let localUuid = tsAccountManager.localUuid else { owsFailDebug("Missing localUuid.") return newGroupMembership @@ -396,15 +396,19 @@ public class GroupManager: NSObject { builder.copyInvalidInvites(from: oldGroupMembership) assert(oldGroupModel.groupsVersion == .V2) - newMembers = newGroupMembership.allUsers.subtracting(oldGroupMembership.allUsers) + newMembers = newGroupMembership.allMembersOfAnyKind.subtracting(oldGroupMembership.allMembersOfAnyKind) // Carry over existing members as they stand. - let existingMembers = oldGroupMembership.allUsers.intersection(newGroupMembership.allUsers) + let existingMembers = oldGroupMembership.allMembersOfAnyKind.intersection(newGroupMembership.allMembersOfAnyKind) for address in existingMembers { - if oldGroupMembership.isPendingMember(address), - newGroupMembership.isNonPendingMember(address) { + if oldGroupMembership.isPendingProfileKeyMember(address), + newGroupMembership.isFullMember(address) { // If we're adding a pending member, treat them as a new member. newMembers.insert(address) + } else if oldGroupMembership.isRequestingMember(address), + newGroupMembership.isFullMember(address) { + // If we're adding a requesting member, treat them as a new member. + newMembers.insert(address) } else { builder.copyMember(address, from: oldGroupMembership) } @@ -413,14 +417,14 @@ public class GroupManager: NSObject { // Creating new group // First, skip separation when creating v1 groups. - guard canUseV2(for: newGroupMembership.allUsers, transaction: transaction) else { + guard canUseV2(for: newGroupMembership.allMembersOfAnyKind, transaction: transaction) else { // If any member of a new group doesn't support groups v2, // we're going to create a v1 group. In that case, we // don't want to separate out pending members. return newGroupMembership } - newMembers = newGroupMembership.allUsers + newMembers = newGroupMembership.allMembersOfAnyKind } // We only need to separate new members. @@ -451,11 +455,11 @@ public class GroupManager: NSObject { // instead of adding them. if address != localAddress && DebugFlags.groupsV2forceInvites { - builder.addPendingMember(address, role: role, addedByUuid: localUuid) + builder.addPendingProfileKeyMember(address, role: role, addedByUuid: localUuid) } else if isPending { - builder.addPendingMember(address, role: role, addedByUuid: localUuid) + builder.addPendingProfileKeyMember(address, role: role, addedByUuid: localUuid) } else { - builder.addNonPendingMember(address, role: role) + builder.addFullMember(address, role: role) } } return builder.build() @@ -574,7 +578,7 @@ public class GroupManager: NSObject { let groupMembership = GroupMembership(v1Members: Set(members)) // GroupsV2 TODO: Let tests specify access levels. // GroupsV2 TODO: Fill in avatarUrlPath when we test v2 groups. - let groupAccess = GroupAccess.allAccess + let groupAccess = GroupAccess.defaultForV1 // Use buildGroupModel() to fill in defaults, like it was a new group. var builder = TSGroupModelBuilder() @@ -654,7 +658,7 @@ public class GroupManager: NSObject { throw OWSAssertionError("Missing localAddress.") } return (groupModel.groupsVersion == .V1 && - groupModel.groupMembership.isPendingOrNonPendingMember(localAddress)) + groupModel.groupMembership.isMemberOfAnyKind(localAddress)) } // MARK: - Update Existing Group (Remote) @@ -667,10 +671,6 @@ public class GroupManager: NSObject { disappearingMessageToken: DisappearingMessageToken?, groupUpdateSourceAddress: SignalServiceAddress?, transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult { - guard let localAddress = tsAccountManager.localAddress else { - throw OWSAssertionError("Missing localAddress.") - } - let groupId = proposedGroupModel.groupId let updateInfo: UpdateInfo do { @@ -794,7 +794,7 @@ public class GroupManager: NSObject { guard RemoteConfig.groupsV2GoodCitizen else { return Promise.value(()) } - return self.tryToEnableGroupsV2(for: Array(proposedGroupModel.groupMembership.allUsers), isBlocking: true, ignoreErrors: true) + return self.tryToEnableGroupsV2(for: Array(proposedGroupModel.groupMembership.allMembersOfAnyKind), isBlocking: true, ignoreErrors: true) }.then(on: .global()) { () -> Promise in return self.ensureLocalProfileHasCommitmentIfNecessary() }.then(on: DispatchQueue.global()) { () -> Promise in @@ -884,7 +884,7 @@ public class GroupManager: NSObject { // Always ensure we're a member of any v1 group we're updating. var builder = proposedGroupModel.groupMembership.asBuilder builder.remove(localAddress) - builder.addNonPendingMember(localAddress, role: .normal) + builder.addFullMember(localAddress, role: .normal) let groupMembership = builder.build() var groupModelBuilder = proposedGroupModel.asBuilder @@ -923,23 +923,19 @@ public class GroupManager: NSObject { let oldDMConfiguration = OWSDisappearingMessagesConfiguration.fetchOrBuildDefault(with: thread, transaction: transaction) let newDMConfiguration = dmConfiguration ?? oldDMConfiguration - for address in proposedGroupMembership.allUsers { + for address in proposedGroupMembership.allMembersOfAnyKind { guard address.uuid != nil else { throw OWSAssertionError("Group v2 member missing uuid.") } } // Before we update a v2 group, we need to separate out the // pending and non-pending members. - let groupMembership = self.separatePendingMembers(in: proposedGroupMembership, - oldGroupModel: oldGroupModel, - transaction: transaction) - - guard groupMembership.nonPendingMembers.contains(localAddress) else { - throw OWSAssertionError("Missing localAddress.") - } + let groupMembership = self.separatePendingProfileKeyMembers(in: proposedGroupMembership, + oldGroupModel: oldGroupModel, + transaction: transaction) // Don't try to modify a v2 group if we're not a member. - guard groupMembership.nonPendingMembers.contains(localAddress) else { + guard groupMembership.isFullMember(localAddress) else { throw OWSAssertionError("Missing localAddress.") } @@ -1108,7 +1104,7 @@ public class GroupManager: NSObject { } return updateGroupV2(groupModel: groupModel, description: "Accept invite") { groupChangeSet in - groupChangeSet.promotePendingMember(localUuid) + groupChangeSet.promotePendingProfileKeyMember(localUuid) } } } @@ -1135,7 +1131,7 @@ public class GroupManager: NSObject { } let oldGroupModel = groupThread.groupModel // Note that we consult allUsers which includes pending members. - guard oldGroupModel.groupMembership.allUsers.contains(localAddress) else { + guard oldGroupModel.groupMembership.isMemberOfAnyKind(localAddress) else { throw OWSAssertionError("Local user is not a member of the group.") } @@ -1185,7 +1181,7 @@ public class GroupManager: NSObject { transaction: SDSAnyWriteTransaction, success: (() -> Void)?) { - guard groupThread.isLocalUserPendingOrNonPendingMember else { + guard groupThread.isLocalUserMemberOfAnyKind else { owsFailDebug("unexpectedly trying to leave group for which we're not a member.") return } @@ -1535,7 +1531,8 @@ public class GroupManager: NSObject { // We need to send v2 group updates to pending members // as well. Normal group sends only include "full members". assert(messageBuilder.additionalRecipients == nil) - let additionalRecipients = groupThread.groupModel.groupMembership.pendingMembers.filter { address in + let groupMembership = groupThread.groupModel.groupMembership + let additionalRecipients = groupMembership.pendingProfileKeyOrRequestMembers.filter { address in return doesUserSupportGroupsV2(address: address, transaction: transaction) } @@ -1585,7 +1582,8 @@ public class GroupManager: NSObject { // We need to send v2 group updates to pending members // as well. Normal group sends only include "full members". assert(messageBuilder.additionalRecipients == nil) - let additionalRecipients = groupThread.groupModel.groupMembership.pendingMembers.filter { address in + let groupMembership = groupThread.groupModel.groupMembership + let additionalRecipients = groupMembership.pendingProfileKeyOrRequestMembers.filter { address in return doesUserSupportGroupsV2(address: address, transaction: transaction) } @@ -1931,7 +1929,7 @@ public class GroupManager: NSObject { // if we're a member of the group. We don't want to do this if we're just a // pending member or are leaving/have already left the group. let groupMembership = groupThread.groupModel.groupMembership - guard groupMembership.isPendingOrNonPendingMember(localAddress) else { + guard groupMembership.isMemberOfAnyKind(localAddress) else { return } profileManager.addThread(toProfileWhitelist: groupThread, transaction: transaction) diff --git a/SignalServiceKit/src/groups/GroupMembership.swift b/SignalServiceKit/src/groups/GroupMembership.swift index 7033898d0c..0d6982e44c 100644 --- a/SignalServiceKit/src/groups/GroupMembership.swift +++ b/SignalServiceKit/src/groups/GroupMembership.swift @@ -29,251 +29,217 @@ public extension TSGroupMemberRole { // MARK: - +// NOTE: We only use this class for backwards compatibility. +@objc(_TtCC16SignalServiceKit15GroupMembership11MemberState) +class LegacyMemberState: MTLModel { + @objc + var role: TSGroupMemberRole = .normal + + @objc + var isPending: Bool = false + + // Only applies for pending members. + @objc + var addedByUuid: UUID? + + @objc + public override init() { + super.init() + } + + init(role: TSGroupMemberRole, + isPending: Bool, + addedByUuid: UUID? = nil) { + self.role = role + self.isPending = isPending + self.addedByUuid = addedByUuid + + super.init() + } + + @objc + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + @objc + public required init(dictionary dictionaryValue: [String: Any]!) throws { + try super.init(dictionary: dictionaryValue) + } + + @objc + public var isAdministrator: Bool { + return role == .administrator + } +} + +// MARK: - + +@objc +public enum GroupMemberType: UInt { + case fullMember = 0 + case pendingProfileKey = 1 + case pendingRequest = 2 +} + +// MARK: - + +extension GroupMemberType: CustomStringConvertible { + public var description: String { + switch self { + case .fullMember: + return ".fullMember" + case .pendingProfileKey: + return ".pendingProfileKey" + case .pendingRequest: + return ".pendingRequest" + } + } +} + +// MARK: - + +// This class is immutable. +@objc(GroupMembershipMemberState) +class MemberState: MTLModel { + @objc + var role: TSGroupMemberRole = .normal + + func memberType() -> GroupMemberType { + .fullMember + } + + @objc + public override init() { + super.init() + } + + init(role: TSGroupMemberRole) { + self.role = role + super.init() + } + + @objc + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + @objc + public required init(dictionary dictionaryValue: [String: Any]!) throws { + try super.init(dictionary: dictionaryValue) + } + + @objc + public var isAdministrator: Bool { + return role == .administrator + } +} + +// MARK: - + +// This class is immutable. +@objc(GroupMembershipFullMemberState) +class FullMemberState: MemberState { + override func memberType() -> GroupMemberType { + .fullMember + } +} + +// MARK: - + +// This class is immutable. +@objc(GroupMembershipPendingProfileKeyMemberState) +class PendingProfileKeyMemberState: MemberState { + + override func memberType() -> GroupMemberType { + .pendingProfileKey + } + + @objc + var addedByUuid: UUID? + + init(role: TSGroupMemberRole, addedByUuid: UUID? = nil) { + self.addedByUuid = addedByUuid + + super.init(role: role) + } + + @objc + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + @objc + public required init(dictionary dictionaryValue: [String: Any]!) throws { + try super.init(dictionary: dictionaryValue) + } +} + +// MARK: - + +// This class is immutable. +@objc(GroupMembershipPendingRequestMemberState) +class PendingRequestMemberState: MemberState { + + override func memberType() -> GroupMemberType { + .pendingRequest + } +} + +// MARK: - + +// This class is immutable. +@objc(GroupMembershipInvalidInviteModel) +class InvalidInviteModel: MTLModel { + @objc + var userId: Data? + + @objc + var addedByUserId: Data? + + @objc + public override init() { + super.init() + } + + init(userId: Data?, addedByUserId: Data? = nil) { + self.userId = userId + self.addedByUserId = addedByUserId + + super.init() + } + + @objc + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + @objc + public required init(dictionary dictionaryValue: [String: Any]!) throws { + try super.init(dictionary: dictionaryValue) + } +} + +// MARK: - + // This class is immutable. @objc public class GroupMembership: MTLModel { - // This class is immutable. - @objc(_TtCC16SignalServiceKit15GroupMembership11MemberState) - class MemberState: MTLModel { - @objc - var role: TSGroupMemberRole = .normal - @objc - var isPending: Bool = false - - // Only applies for pending members. - @objc - var addedByUuid: UUID? - - @objc - public override init() { - super.init() - } - - init(role: TSGroupMemberRole, - isPending: Bool, - addedByUuid: UUID? = nil) { - self.role = role - self.isPending = isPending - self.addedByUuid = addedByUuid - - super.init() - } - - @objc - required public init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - @objc - public required init(dictionary dictionaryValue: [String: Any]!) throws { - try super.init(dictionary: dictionaryValue) - } - - @objc - public var isAdministrator: Bool { - return role == .administrator - } - } - - // This class is immutable. - @objc(GroupMembershipInvalidInviteModel) - class InvalidInviteModel: MTLModel { - @objc - var userId: Data? - - @objc - var addedByUserId: Data? - - @objc - public override init() { - super.init() - } - - init(userId: Data?, addedByUserId: Data? = nil) { - self.userId = userId - self.addedByUserId = addedByUserId - - super.init() - } - - @objc - required public init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - @objc - public required init(dictionary dictionaryValue: [String: Any]!) throws { - try super.init(dictionary: dictionaryValue) - } - } + typealias LegacyMemberStateMap = [SignalServiceAddress: LegacyMemberState] // By using a single dictionary we ensure that no address has more than one state. typealias MemberStateMap = [SignalServiceAddress: MemberState] @objc - var memberStateMap: MemberStateMap + var memberStates: MemberStateMap typealias InvalidInviteMap = [Data: InvalidInviteModel] @objc var invalidInviteMap: InvalidInviteMap - @objc - public var nonAdminMembers: Set { - return Set(memberStateMap.filter { !$0.value.isAdministrator && !$0.value.isPending }.keys) - } - @objc - public var nonPendingAdministrators: Set { - return Set(memberStateMap.filter { $0.value.isAdministrator && !$0.value.isPending }.keys) - } - @objc - public var nonPendingMembers: Set { - return Set(memberStateMap.filter { !$0.value.isPending }.keys) - } - - @objc - public var pendingNonAdminMembers: Set { - return Set(memberStateMap.filter { !$0.value.isAdministrator && $0.value.isPending }.keys) - } - @objc - public var pendingAdministrators: Set { - return Set(memberStateMap.filter { $0.value.isAdministrator && $0.value.isPending }.keys) - } - // pendingMembers includes normal and administrator "pending members". - @objc - public var pendingMembers: Set { - return Set(memberStateMap.filter { $0.value.isPending }.keys) - } - - @objc - public var pendingAndNonPendingMemberCount: Int { - return memberStateMap.count - } - - // allUsers includes _all_ users: - // - // * Normal and administrator. - // * Pending and non-pending. - @objc - public var allUsers: Set { - return Set(memberStateMap.keys) - } - - public struct Builder { - private var memberStateMap = MemberStateMap() - private var invalidInviteMap = InvalidInviteMap() - - public init() {} - - internal init(memberStateMap: MemberStateMap, invalidInviteMap: InvalidInviteMap) { - self.memberStateMap = memberStateMap - self.invalidInviteMap = invalidInviteMap - } - - public mutating func remove(_ uuid: UUID) { - remove(SignalServiceAddress(uuid: uuid)) - } - - public mutating func remove(_ address: SignalServiceAddress) { - memberStateMap.removeValue(forKey: address) - } - - public mutating func remove(_ addresses: Set) { - for address in addresses { - remove(address) - } - } - - public mutating func addNonPendingMember(_ uuid: UUID, - role: TSGroupMemberRole) { - addNonPendingMember(SignalServiceAddress(uuid: uuid), role: role) - } - - public mutating func addNonPendingMember(_ address: SignalServiceAddress, - role: TSGroupMemberRole) { - addNonPendingMembers([address], role: role) - } - - public mutating func addNonPendingMembers(_ addresses: Set, - role: TSGroupMemberRole) { - for address in addresses { - if memberStateMap[address] != nil { - owsFailDebug("Duplicate address.") - } - memberStateMap[address] = MemberState(role: role, isPending: false, addedByUuid: nil) - } - } - - public mutating func addPendingMember(_ uuid: UUID, - role: TSGroupMemberRole, - addedByUuid: UUID) { - addPendingMember(SignalServiceAddress(uuid: uuid), role: role, addedByUuid: addedByUuid) - } - - public mutating func addPendingMember(_ address: SignalServiceAddress, - role: TSGroupMemberRole, - addedByUuid: UUID) { - addPendingMembers([address], role: role, addedByUuid: addedByUuid) - } - - public mutating func addPendingMembers(_ addresses: Set, - role: TSGroupMemberRole, - addedByUuid: UUID) { - for address in addresses { - if memberStateMap[address] != nil { - Logger.error("Duplicate address.") - continue -// owsFailDebug("Duplicate address.") - } - memberStateMap[address] = MemberState(role: role, isPending: true, addedByUuid: addedByUuid) - } - } - - public mutating func copyMember(_ address: SignalServiceAddress, - from oldGroupMembership: GroupMembership) { - guard let memberState = oldGroupMembership.memberStateMap[address] else { - owsFailDebug("Unknown address") - return - } - if memberStateMap[address] != nil { - owsFailDebug("Duplicate address.") - } - memberStateMap[address] = memberState - } - - 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 mutating func copyInvalidInvites(from other: GroupMembership) { - assert(invalidInviteMap.isEmpty) - invalidInviteMap = other.invalidInviteMap - } - - internal func asMemberStateMap() -> MemberStateMap { - return memberStateMap - } - - public func build() -> GroupMembership { - var memberStateMap = self.memberStateMap - - let localProfileInvariantAddress = SignalServiceAddress(phoneNumber: kLocalProfileInvariantPhoneNumber) - if memberStateMap[localProfileInvariantAddress] != nil { - owsFailDebug("Removing localProfileInvariantAddress.") - memberStateMap.removeValue(forKey: localProfileInvariantAddress) - } - - return GroupMembership(memberStateMap: memberStateMap, - invalidInviteMap: invalidInviteMap) - } - } - @objc public override init() { - self.memberStateMap = MemberStateMap() + self.memberStates = MemberStateMap() self.invalidInviteMap = [:] super.init() @@ -281,22 +247,47 @@ public class GroupMembership: MTLModel { @objc required public init?(coder aDecoder: NSCoder) { - self.memberStateMap = MemberStateMap() - self.invalidInviteMap = [:] + if let invalidInviteMap = aDecoder.decodeObject(forKey: "invalidInviteMap") as? InvalidInviteMap { + self.invalidInviteMap = invalidInviteMap + } else { + // invalidInviteMap is optional. + self.invalidInviteMap = [:] + } - super.init(coder: aDecoder) + if let memberStates = aDecoder.decodeObject(forKey: "memberStates") as? MemberStateMap { + self.memberStates = memberStates + } else if let legacyMemberStateMap = aDecoder.decodeObject(forKey: "memberStateMap") as? LegacyMemberStateMap { + self.memberStates = Self.convertLegacyMemberStateMap(legacyMemberStateMap) + } else { + owsFailDebug("Could not decode.") + return nil + } + + super.init() } @objc public required init(dictionary dictionaryValue: [String: Any]!) throws { - self.memberStateMap = MemberStateMap() - self.invalidInviteMap = [:] + if let invalidInviteMap = dictionaryValue["invalidInviteMap"] as? InvalidInviteMap { + self.invalidInviteMap = invalidInviteMap + } else { + // invalidInviteMap is optional. + self.invalidInviteMap = [:] + } - try super.init(dictionary: dictionaryValue) + if let memberStates = dictionaryValue["memberStates"] as? MemberStateMap { + self.memberStates = memberStates + } else if let legacyMemberStateMap = dictionaryValue["memberStateMap"] as? LegacyMemberStateMap { + self.memberStates = Self.convertLegacyMemberStateMap(legacyMemberStateMap) + } else { + throw OWSAssertionError("Could not decode.") + } + + super.init() } - internal init(memberStateMap: MemberStateMap, invalidInviteMap: InvalidInviteMap) { - self.memberStateMap = memberStateMap + internal init(memberStates: MemberStateMap, invalidInviteMap: InvalidInviteMap) { + self.memberStates = memberStates self.invalidInviteMap = invalidInviteMap super.init() @@ -305,89 +296,38 @@ public class GroupMembership: MTLModel { @objc public init(v1Members: Set) { var builder = Builder() - builder.addNonPendingMembers(v1Members, role: .normal) - self.memberStateMap = builder.asMemberStateMap() + builder.addFullMembers(v1Members, role: .normal) + self.memberStates = builder.asMemberStateMap() self.invalidInviteMap = [:] super.init() } + private static func convertLegacyMemberStateMap(_ legacyMemberStateMap: LegacyMemberStateMap) -> MemberStateMap { + var result = MemberStateMap() + for (address, legacyMemberState) in legacyMemberStateMap { + let memberState: MemberState + if legacyMemberState.isPending { + if let addedByUuid = legacyMemberState.addedByUuid { + memberState = PendingProfileKeyMemberState(role: legacyMemberState.role, + addedByUuid: addedByUuid) + } else { + owsFailDebug("Missing addedByUuid.") + continue + } + } else { + memberState = FullMemberState(role: legacyMemberState.role) + } + result[address] = memberState + } + return result + } + @objc public static var empty: GroupMembership { return Builder().build() } - public func role(for uuid: UUID) -> TSGroupMemberRole? { - return role(for: SignalServiceAddress(uuid: uuid)) - } - - public func role(for address: SignalServiceAddress) -> TSGroupMemberRole? { - guard let memberState = memberStateMap[address] else { - return nil - } - return memberState.role - } - - public func isAdministrator(_ address: SignalServiceAddress) -> Bool { - guard let memberState = memberStateMap[address] else { - return false - } - return memberState.isAdministrator - } - - public func isAdministrator(_ uuid: UUID) -> Bool { - return isAdministrator(SignalServiceAddress(uuid: uuid)) - } - - @objc - public func isNonPendingMember(_ address: SignalServiceAddress) -> Bool { - guard let memberState = memberStateMap[address] else { - return false - } - return !memberState.isPending - } - - public func isNonPendingMember(_ uuid: UUID) -> Bool { - return isNonPendingMember(SignalServiceAddress(uuid: uuid)) - } - - @objc - public func isPendingMember(_ address: SignalServiceAddress) -> Bool { - guard let memberState = memberStateMap[address] else { - return false - } - return memberState.isPending - } - - public func isPendingMember(_ uuid: UUID) -> Bool { - isPendingMember(SignalServiceAddress(uuid: uuid)) - } - - // When we check "is X a member?" we might mean... - // - // * Is X a "full" member or a pending member? - // * Is X a "full" member and not a pending member? - // * Is X a "normal" member and not an administrator member? - // * Is X a "normal" member or an administrator member? - // * Some combination thereof. - // - // This method is intended tests the inclusive case: pending - // or non-pending, any role. - @objc - public func isPendingOrNonPendingMember(_ address: SignalServiceAddress) -> Bool { - return memberStateMap[address] != nil - } - - public func isPendingOrNonPendingMember(_ uuid: UUID) -> Bool { - return isPendingOrNonPendingMember(SignalServiceAddress(uuid: uuid)) - } - - public func addedByUuid(forPendingMember address: SignalServiceAddress) -> UUID? { - assert(isPendingMember(address)) - - return memberStateMap[address]?.addedByUuid - } - @objc public static func normalize(_ addresses: [SignalServiceAddress]) -> [SignalServiceAddress] { return Array(Set(addresses)) @@ -415,19 +355,309 @@ public class GroupMembership: MTLModel { } public var asBuilder: Builder { - return Builder(memberStateMap: memberStateMap, invalidInviteMap: invalidInviteMap) + return Builder(memberStates: memberStates, invalidInviteMap: invalidInviteMap) } public override var debugDescription: String { var result = "[" - for address in GroupMembership.normalize(Array(allUsers)) { - guard let memberState = memberStateMap[address] else { + for address in GroupMembership.normalize(Array(allMembersOfAnyKind)) { + guard let memberState = memberStates[address] else { owsFailDebug("Missing memberState.") continue } - result += "\(address), isPending: \(memberState.isPending), isAdministrator: \(memberState.isAdministrator)\n" + result += "\(address), memberType: \(memberState.memberType()), role: \(memberState.role)\n" } result += "]" return result } } + +// MARK: - Swift Accessors + +public extension GroupMembership { + + var fullMemberAdministrators: Set { + return Set(memberStates.filter { $0.value.isAdministrator && $0.value.memberType() == .fullMember }.keys) + } + + var fullMembers: Set { + return Set(memberStates.filter { $0.value.memberType() == .fullMember }.keys) + } + + var pendingProfileKeyMembers: Set { + return Set(memberStates.filter { $0.value.memberType() == .pendingProfileKey }.keys) + } + + var pendingRequestMembers: Set { + return Set(memberStates.filter { $0.value.memberType() == .pendingRequest }.keys) + } + + var fullOrPendingProfileKeyMembers: Set { + let memberTypes: [GroupMemberType] = [ .fullMember, .pendingProfileKey ] + return Set(memberStates.filter { memberTypes.contains($0.value.memberType()) }.keys) + } + + var pendingProfileKeyOrRequestMembers: Set { + let memberTypes: [GroupMemberType] = [ .pendingProfileKey, .pendingRequest ] + return Set(memberStates.filter { memberTypes.contains($0.value.memberType()) }.keys) + } + + // allMembersOfAnyKind includes _all_ members: + // + // * Normal and administrator. + // * Normal, pending profile key, requesting. + var allMembersOfAnyKind: Set { + return Set(memberStates.keys) + } + + // allUsers includes _all_ members: + // + // * Normal and administrator. + // * Normal, pending profile key, requesting. + var allMemberUuidsOfAnyKind: Set { + return Set(memberStates.keys.compactMap { $0.uuid }) + } + + func role(for uuid: UUID) -> TSGroupMemberRole? { + return role(for: SignalServiceAddress(uuid: uuid)) + } + + func role(for address: SignalServiceAddress) -> TSGroupMemberRole? { + guard let memberState = memberStates[address] else { + return nil + } + return memberState.role + } + + func isFullOrInvitedAdministrator(_ address: SignalServiceAddress) -> Bool { + guard let memberState = memberStates[address] else { + return false + } + guard memberState.isAdministrator else { + return false + } + switch memberState.memberType() { + case .fullMember, .pendingProfileKey: + return true + case .pendingRequest: + return false + } + } + + func isFullOrInvitedAdministrator(_ uuid: UUID) -> Bool { + return isFullOrInvitedAdministrator(SignalServiceAddress(uuid: uuid)) + } + + func isFullMemberAndAdministrator(_ address: SignalServiceAddress) -> Bool { + guard let memberState = memberStates[address] else { + return false + } + return memberState.isAdministrator && memberState.memberType() == .fullMember + } + + func isFullMemberAndAdministrator(_ uuid: UUID) -> Bool { + return isFullMemberAndAdministrator(SignalServiceAddress(uuid: uuid)) + } + + @objc + func isFullMember(_ address: SignalServiceAddress) -> Bool { + guard let memberState = memberStates[address] else { + return false + } + return memberState.memberType() == .fullMember + } + + func isFullMember(_ uuid: UUID) -> Bool { + isFullMember(SignalServiceAddress(uuid: uuid)) + } + + @objc + func isPendingProfileKeyMember(_ address: SignalServiceAddress) -> Bool { + guard let memberState = memberStates[address] else { + return false + } + return memberState.memberType() == .pendingProfileKey + } + + func isPendingProfileKeyMember(_ uuid: UUID) -> Bool { + isPendingProfileKeyMember(SignalServiceAddress(uuid: uuid)) + } + + func isRequestingMember(_ address: SignalServiceAddress) -> Bool { + guard let memberState = memberStates[address] else { + return false + } + return memberState.memberType() == .pendingRequest + } + + func isRequestingMember(_ uuid: UUID) -> Bool { + isRequestingMember(SignalServiceAddress(uuid: uuid)) + } + + // Returns true for... + // + // * Any type: Full members, "pending invite" members, "requesting" members. + // * Any role: admin, non-admin. + // + // This method is intended tests the inclusive case: pending + // or non-pending, any role. + // + // This method does NOT return true for invalid invites, + // which don't have a UUID or address associated with them. + func isMemberOfAnyKind(_ address: SignalServiceAddress) -> Bool { + return memberStates[address] != nil + } + + func isMemberOfAnyKind(_ uuid: UUID) -> Bool { + return isMemberOfAnyKind(SignalServiceAddress(uuid: uuid)) + } + + // This method should only be called for "pending profile key" members. + func addedByUuid(forPendingProfileKeyMember address: SignalServiceAddress) -> UUID? { + assert(isPendingProfileKeyMember(address)) + + guard let memberState = memberStates[address] else { + return nil + } + guard let pendingProfileKeyMemberState = memberState as? PendingProfileKeyMemberState else { + owsFailDebug("Unexpected member type.") + return nil + } + return pendingProfileKeyMemberState.addedByUuid + } +} + +// MARK: - Builder + +@objc +public extension GroupMembership { + struct Builder { + private var memberStates = MemberStateMap() + private var invalidInviteMap = InvalidInviteMap() + + public init() {} + + internal init(memberStates: MemberStateMap, invalidInviteMap: InvalidInviteMap) { + self.memberStates = memberStates + self.invalidInviteMap = invalidInviteMap + } + + public mutating func remove(_ uuid: UUID) { + remove(SignalServiceAddress(uuid: uuid)) + } + + public mutating func remove(_ address: SignalServiceAddress) { + memberStates.removeValue(forKey: address) + } + + public mutating func remove(_ addresses: Set) { + for address in addresses { + remove(address) + } + } + + public mutating func addFullMember(_ uuid: UUID, + role: TSGroupMemberRole) { + addFullMember(SignalServiceAddress(uuid: uuid), role: role) + } + + public mutating func addFullMember(_ address: SignalServiceAddress, + role: TSGroupMemberRole) { + addFullMembers([address], role: role) + } + + public mutating func addFullMembers(_ addresses: Set, + role: TSGroupMemberRole) { + for address in addresses { + if memberStates[address] != nil { + owsFailDebug("Duplicate address.") + } + memberStates[address] = FullMemberState(role: role) + } + } + + public mutating func addPendingProfileKeyMember(_ uuid: UUID, + role: TSGroupMemberRole, + addedByUuid: UUID) { + addPendingProfileKeyMember(SignalServiceAddress(uuid: uuid), role: role, addedByUuid: addedByUuid) + } + + public mutating func addPendingProfileKeyMember(_ address: SignalServiceAddress, + role: TSGroupMemberRole, + addedByUuid: UUID) { + addPendingProfileKeyMembers([address], role: role, addedByUuid: addedByUuid) + } + + public mutating func addPendingProfileKeyMembers(_ addresses: Set, + role: TSGroupMemberRole, + addedByUuid: UUID) { + for address in addresses { + if memberStates[address] != nil { + owsFailDebug("Duplicate address.") + continue + } + memberStates[address] = PendingProfileKeyMemberState(role: role, addedByUuid: addedByUuid) + } + } + + public mutating func addRequestingMember(_ uuid: UUID) { + addRequestingMember(SignalServiceAddress(uuid: uuid)) + } + + public mutating func addRequestingMember(_ address: SignalServiceAddress) { + addRequestingMembers([address]) + } + + public mutating func addRequestingMembers(_ addresses: Set) { + for address in addresses { + if memberStates[address] != nil { + owsFailDebug("Duplicate address.") + continue + } + memberStates[address] = PendingRequestMemberState() + } + } + + public mutating func copyMember(_ address: SignalServiceAddress, + from oldGroupMembership: GroupMembership) { + guard let memberState = oldGroupMembership.memberStates[address] else { + owsFailDebug("Unknown address") + return + } + if memberStates[address] != nil { + owsFailDebug("Duplicate address.") + } + memberStates[address] = memberState + } + + 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 mutating func copyInvalidInvites(from other: GroupMembership) { + assert(invalidInviteMap.isEmpty) + invalidInviteMap = other.invalidInviteMap + } + + internal func asMemberStateMap() -> MemberStateMap { + return memberStates + } + + public func build() -> GroupMembership { + var memberStates = self.memberStates + + let localProfileInvariantAddress = SignalServiceAddress(phoneNumber: kLocalProfileInvariantPhoneNumber) + if memberStates[localProfileInvariantAddress] != nil { + owsFailDebug("Removing localProfileInvariantAddress.") + memberStates.removeValue(forKey: localProfileInvariantAddress) + } + + return GroupMembership(memberStates: memberStates, + invalidInviteMap: invalidInviteMap) + } + } +} diff --git a/SignalServiceKit/src/groups/GroupsV2.swift b/SignalServiceKit/src/groups/GroupsV2.swift index 80a5663dc1..081051874d 100644 --- a/SignalServiceKit/src/groups/GroupsV2.swift +++ b/SignalServiceKit/src/groups/GroupsV2.swift @@ -99,6 +99,7 @@ public protocol GroupsV2Swift: GroupsV2 { // MARK: - +// TODO: Rename to GroupsV2ProposedChanges public protocol GroupsV2ChangeSet: AnyObject { var groupId: Data { get } var groupSecretParamsData: Data { get } @@ -116,7 +117,7 @@ public protocol GroupsV2ChangeSet: AnyObject { func setAccessForAttributes(_ value: GroupV2Access) - func promotePendingMember(_ uuid: UUID) + func promotePendingProfileKeyMember(_ uuid: UUID) func setShouldLeaveGroupDeclineInvite() @@ -216,12 +217,11 @@ public protocol GroupV2Snapshot { var groupAccess: GroupAccess { get } - var accessControlForAttributes: GroupsProtoAccessControlAccessRequired { get } - var accessControlForMembers: GroupsProtoAccessControlAccessRequired { get } - var disappearingMessageToken: DisappearingMessageToken { get } var profileKeys: [UUID: Data] { get } + + var inviteLinkPassword: Data? { get } } // MARK: - diff --git a/SignalServiceKit/src/groups/TSGroupModel.swift b/SignalServiceKit/src/groups/TSGroupModel.swift index 3b896c112b..9914180ea2 100644 --- a/SignalServiceKit/src/groups/TSGroupModel.swift +++ b/SignalServiceKit/src/groups/TSGroupModel.swift @@ -24,6 +24,8 @@ public class TSGroupModelV2: TSGroupModel { public var revision: UInt32 = 0 @objc public var avatarUrlPath: String? + @objc + public var inviteLinkPassword: Data? @objc public required init(groupId: Data, @@ -33,7 +35,8 @@ public class TSGroupModelV2: TSGroupModel { groupAccess: GroupAccess, revision: UInt32, secretParamsData: Data, - avatarUrlPath: String?) { + avatarUrlPath: String?, + inviteLinkPassword: Data?) { assert(secretParamsData.count > 0) self.membership = groupMembership @@ -41,11 +44,12 @@ public class TSGroupModelV2: TSGroupModel { self.access = groupAccess self.revision = revision self.avatarUrlPath = avatarUrlPath + self.inviteLinkPassword = inviteLinkPassword super.init(groupId: groupId, name: name, avatarData: avatarData, - members: Array(groupMembership.nonPendingMembers)) + members: Array(groupMembership.fullMembers)) } // MARK: - MTLModel @@ -74,7 +78,7 @@ public class TSGroupModelV2: TSGroupModel { @objc public override var groupMembers: [SignalServiceAddress] { - return Array(groupMembership.nonPendingMembers) + return Array(groupMembership.fullMembers) } public override func isEqual(to model: TSGroupModel, @@ -100,6 +104,9 @@ public class TSGroupModelV2: TSGroupModel { guard other.avatarUrlPath == avatarUrlPath else { return false } + guard other.inviteLinkPassword == inviteLinkPassword else { + return false + } return true } @@ -115,6 +122,7 @@ public class TSGroupModelV2: TSGroupModel { result += "secretParamsData: \(secretParamsData.hexadecimalString.prefix(32)),\n" result += "revision: \(revision),\n" result += "avatarUrlPath: \(String(describing: avatarUrlPath)),\n" + result += "inviteLinkPassword: \(inviteLinkPassword?.hexadecimalString ?? "None"),\n" result += "]" return result } diff --git a/SignalServiceKit/src/groups/TSGroupModelBuilder.swift b/SignalServiceKit/src/groups/TSGroupModelBuilder.swift index 222b53d2ef..a67ab96da9 100644 --- a/SignalServiceKit/src/groups/TSGroupModelBuilder.swift +++ b/SignalServiceKit/src/groups/TSGroupModelBuilder.swift @@ -24,6 +24,7 @@ public struct TSGroupModelBuilder { public var groupSecretParamsData: Data? public var newGroupSeed: NewGroupSeed? public var avatarUrlPath: String? + public var inviteLinkPassword: Data? public init() {} @@ -39,11 +40,12 @@ public struct TSGroupModelBuilder { self.groupV2Revision = groupV2Snapshot.revision self.groupSecretParamsData = groupV2Snapshot.groupSecretParamsData self.avatarUrlPath = groupV2Snapshot.avatarUrlPath + self.inviteLinkPassword = groupV2Snapshot.inviteLinkPassword } public func build(transaction: SDSAnyReadTransaction) throws -> TSGroupModel { - let allUsers = groupMembership.allUsers + let allUsers = groupMembership.allMembersOfAnyKind for recipientAddress in allUsers { guard recipientAddress.isValid else { throw OWSAssertionError("Invalid address.") @@ -70,13 +72,16 @@ public struct TSGroupModelBuilder { switch groupsVersion { case .V1: - if groupMembership.pendingMembers.count > 0 { - owsFailDebug("v1 group has pending members.") + if !groupMembership.pendingProfileKeyMembers.isEmpty { + owsFailDebug("v1 group has pending profile key members.") + } + if !groupMembership.pendingRequestMembers.isEmpty { + owsFailDebug("v1 group has pending request members.") } return TSGroupModel(groupId: groupId, name: name, avatarData: avatarData, - members: Array(groupMembership.nonPendingMembers)) + members: Array(groupMembership.fullMembers)) case .V2: let groupAccess = buildGroupAccess(groupsVersion: groupsVersion) let groupSecretParamsData = try buildGroupSecretParamsData(newGroupSeed: newGroupSeed) @@ -90,7 +95,8 @@ public struct TSGroupModelBuilder { groupAccess: groupAccess, revision: groupV2Revision, secretParamsData: groupSecretParamsData, - avatarUrlPath: avatarUrlPath) + avatarUrlPath: avatarUrlPath, + inviteLinkPassword: inviteLinkPassword) } } @@ -182,6 +188,7 @@ public extension TSGroupModel { builder.groupV2Revision = v2.revision builder.groupSecretParamsData = v2.secretParamsData builder.avatarUrlPath = v2.avatarUrlPath + builder.inviteLinkPassword = v2.inviteLinkPassword } return builder }