860 lines
38 KiB
Swift
860 lines
38 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SignalServiceKit
|
|
import ZKGroup
|
|
|
|
// Represents a proposed set of changes to a group.
|
|
//
|
|
// There are up to three group revisions involved:
|
|
//
|
|
// * "old" (e.g. oldGroupModel): the group model before the changes were made.
|
|
// * "modified" (e.g. modifiedGroupModel): the group model after the changes were made.
|
|
// * "current" (e.g. currentGroupModel): the group model at the time we apply the changes.
|
|
//
|
|
// Example:
|
|
//
|
|
// * User edits a group at "old" revision N.
|
|
// * Client diff against a "modified" group model and determines that the title changed
|
|
// and captures that in this instance.
|
|
// * We try to update the group on the service, computing a GroupChange proto against
|
|
// the latest known revision N.
|
|
// * Another client has made (possibly conflicting) changes. Group is now at revision
|
|
// N+1 on service.
|
|
// * We try again, computing a new GroupChange proto against revision N+1.
|
|
//
|
|
// This class serves two roles:
|
|
//
|
|
// * To capture the user intent (i.e. the difference between "old" and "modified").
|
|
// * To try to generate a "change" proto that applies that intent to the latest group state.
|
|
//
|
|
// The latter can be non-trivial:
|
|
//
|
|
// * If we try to add a new member and another user beats us to it, we'll throw
|
|
// GroupsV2Error.redundantChange when computing a GroupChange proto.
|
|
// * If we add (alice and bob) but another user adds (alice) first, we'll just add (bob).
|
|
@objc
|
|
public class GroupsV2OutgoingChangesImpl: NSObject, GroupsV2OutgoingChanges {
|
|
|
|
public let groupId: Data
|
|
public let groupSecretParamsData: Data
|
|
|
|
// MARK: -
|
|
|
|
// These properties capture the original intent of the local user.
|
|
//
|
|
// NOTE: These properties generally _DO NOT_ capture the new state of the group;
|
|
// they capture only "changed" aspects of group state.
|
|
//
|
|
// NOTE: Even if set, these properties _DO NOT_ necessarily translate into
|
|
// "change actions"; we only need to build change actions if _current_ group
|
|
// state differs from the "changed" group state. Our client might race with
|
|
// similar changes made by other group members/clients. We can & must skip
|
|
// redundant changes.
|
|
|
|
// Non-nil if the title changed.
|
|
// When clearing the title, this will be the empty string.
|
|
private var newTitle: String?
|
|
private var newDescriptionText: String?
|
|
|
|
public var newAvatarData: Data?
|
|
public var newAvatarUrlPath: String?
|
|
private var shouldUpdateAvatar = false
|
|
|
|
private var membersToAdd = [UUID: TSGroupMemberRole]()
|
|
// Full, pending profile key or pending request members to remove.
|
|
private var membersToRemove = [UUID]()
|
|
private var membersToChangeRole = [UUID: TSGroupMemberRole]()
|
|
private var invitedMembersToAdd = [UUID: TSGroupMemberRole]()
|
|
private var invalidInvitesToRemove = [Data: InvalidInvite]()
|
|
private var invitedMembersToPromote = [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?
|
|
|
|
private enum InviteLinkPasswordMode {
|
|
case ignore
|
|
case rotate
|
|
case ensureValid
|
|
}
|
|
|
|
private var inviteLinkPasswordMode: InviteLinkPasswordMode?
|
|
|
|
private var shouldLeaveGroupDeclineInvite = false
|
|
private var shouldRevokeInvalidInvites = false
|
|
|
|
// Non-nil if the value changed.
|
|
private var isAnnouncementsOnly: Bool?
|
|
|
|
private var shouldUpdateLocalProfileKey = false
|
|
|
|
private var newLinkMode: GroupsV2LinkMode?
|
|
|
|
// Non-nil if dm state changed.
|
|
private var newDisappearingMessageToken: DisappearingMessageToken?
|
|
|
|
@objc
|
|
public required init(groupId: Data,
|
|
groupSecretParamsData: Data) {
|
|
self.groupId = groupId
|
|
self.groupSecretParamsData = groupSecretParamsData
|
|
}
|
|
|
|
@objc
|
|
public required init(for groupModel: TSGroupModelV2) throws {
|
|
self.groupId = groupModel.groupId
|
|
self.groupSecretParamsData = groupModel.secretParamsData
|
|
}
|
|
|
|
// MARK: - Original Intent
|
|
|
|
// Calculate the intended changes of the local user
|
|
// by diffing two group models.
|
|
@objc
|
|
public func buildChangeSet(oldGroupModel: TSGroupModelV2,
|
|
newGroupModel: TSGroupModelV2,
|
|
oldDMConfiguration: OWSDisappearingMessagesConfiguration,
|
|
newDMConfiguration: OWSDisappearingMessagesConfiguration,
|
|
transaction: SDSAnyReadTransaction) throws {
|
|
guard groupId == oldGroupModel.groupId else {
|
|
throw OWSAssertionError("Mismatched groupId.")
|
|
}
|
|
guard groupId == newGroupModel.groupId else {
|
|
throw OWSAssertionError("Mismatched groupId.")
|
|
}
|
|
|
|
// GroupsV2 TODO: Will production implementation of encryptString() pad?
|
|
let oldTitle = oldGroupModel.groupName?.ows_stripped() ?? " "
|
|
let newTitle = newGroupModel.groupName?.ows_stripped() ?? " "
|
|
if oldTitle != newTitle {
|
|
setTitle(newTitle)
|
|
}
|
|
|
|
let oldDescription = oldGroupModel.descriptionText?.nilIfEmpty?.ows_stripped()
|
|
let newDescription = newGroupModel.descriptionText?.nilIfEmpty?.ows_stripped()
|
|
if oldDescription != newDescription {
|
|
setDescriptionText(newDescription)
|
|
}
|
|
|
|
if oldGroupModel.groupAvatarData != newGroupModel.groupAvatarData {
|
|
let hasAvatarUrlPath = newGroupModel.avatarUrlPath != nil
|
|
let hasAvatarData = newGroupModel.groupAvatarData != nil
|
|
guard hasAvatarUrlPath == hasAvatarData else {
|
|
throw OWSAssertionError("hasAvatarUrlPath: \(hasAvatarData) != hasAvatarData.")
|
|
}
|
|
|
|
setAvatar(avatarData: newGroupModel.groupAvatarData,
|
|
avatarUrlPath: newGroupModel.avatarUrlPath)
|
|
}
|
|
|
|
let oldGroupMembership = oldGroupModel.groupMembership
|
|
let newGroupMembership = newGroupModel.groupMembership
|
|
|
|
let oldUserUuids = oldGroupMembership.allMemberUuidsOfAnyKind
|
|
let newUserUuids = newGroupMembership.allMemberUuidsOfAnyKind
|
|
|
|
for uuid in newUserUuids.subtracting(oldUserUuids) {
|
|
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.isInvitedMember(uuid)
|
|
let role: TSGroupMemberRole = isAdministrator ? .administrator : .normal
|
|
if isPending {
|
|
addInvitedMember(uuid, role: role)
|
|
} else {
|
|
addMember(uuid, role: role)
|
|
}
|
|
}
|
|
|
|
for uuid in oldUserUuids.subtracting(newUserUuids) {
|
|
removeMember(uuid)
|
|
}
|
|
|
|
for invalidInvite in oldGroupMembership.invalidInvites {
|
|
if !newGroupMembership.hasInvalidInvite(forUserId: invalidInvite.userId) {
|
|
removeInvalidInvite(invalidInvite: invalidInvite)
|
|
}
|
|
}
|
|
|
|
for uuid in oldUserUuids.intersection(newUserUuids) {
|
|
if oldGroupMembership.isInvitedMember(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.fullMembers.compactMap { $0.uuid })
|
|
let newMemberUuids = Set(newGroupMembership.fullMembers.compactMap { $0.uuid })
|
|
for uuid in oldMemberUuids.intersection(newMemberUuids) {
|
|
let oldIsAdministrator = oldGroupMembership.isFullMemberAndAdministrator(uuid)
|
|
let newIsAdministrator = newGroupMembership.isFullMemberAndAdministrator(uuid)
|
|
guard oldIsAdministrator != newIsAdministrator else {
|
|
continue
|
|
}
|
|
let role: TSGroupMemberRole = newIsAdministrator ? .administrator : .normal
|
|
changeRoleForMember(uuid, role: role)
|
|
}
|
|
|
|
if oldGroupModel.inviteLinkPassword != newGroupModel.inviteLinkPassword {
|
|
owsFailDebug("We should never change the invite link password by diffing group models.")
|
|
}
|
|
|
|
let oldAccess = oldGroupModel.access
|
|
let newAccess = newGroupModel.access
|
|
if oldAccess.members != newAccess.members {
|
|
setAccessForMembers(newAccess.members)
|
|
}
|
|
if oldAccess.attributes != newAccess.attributes {
|
|
setAccessForAttributes(newAccess.attributes)
|
|
}
|
|
if oldAccess.addFromInviteLink != newAccess.addFromInviteLink {
|
|
owsFailDebug("We should never change the invite link access by diffing group models.")
|
|
}
|
|
|
|
let oldDisappearingMessageToken = oldDMConfiguration.asToken
|
|
let newDisappearingMessageToken = newDMConfiguration.asToken
|
|
if oldDisappearingMessageToken != newDisappearingMessageToken {
|
|
setNewDisappearingMessageToken(newDisappearingMessageToken)
|
|
}
|
|
|
|
if oldGroupModel.isAnnouncementsOnly != newGroupModel.isAnnouncementsOnly {
|
|
self.isAnnouncementsOnly = newGroupModel.isAnnouncementsOnly
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public func setTitle(_ value: String?) {
|
|
owsAssertDebug(self.newTitle == nil)
|
|
// Non-nil if the title changed.
|
|
self.newTitle = value ?? ""
|
|
}
|
|
|
|
@objc
|
|
public func setDescriptionText(_ value: String?) {
|
|
owsAssertDebug(self.newDescriptionText == nil)
|
|
self.newDescriptionText = value ?? ""
|
|
}
|
|
|
|
@objc
|
|
public func setAvatar(avatarData: Data?, avatarUrlPath: String?) {
|
|
owsAssertDebug(self.newAvatarData == nil)
|
|
owsAssertDebug(self.newAvatarUrlPath == nil)
|
|
owsAssertDebug(!self.shouldUpdateAvatar)
|
|
|
|
self.newAvatarData = avatarData
|
|
self.newAvatarUrlPath = avatarUrlPath
|
|
self.shouldUpdateAvatar = true
|
|
}
|
|
|
|
public func addMember(_ uuid: UUID, role: TSGroupMemberRole) {
|
|
owsAssertDebug(membersToAdd[uuid] == nil)
|
|
membersToAdd[uuid] = role
|
|
}
|
|
|
|
@objc
|
|
public func removeMember(_ uuid: UUID) {
|
|
owsAssertDebug(!membersToRemove.contains(uuid))
|
|
membersToRemove.append(uuid)
|
|
}
|
|
|
|
@objc
|
|
public func promoteInvitedMember(_ uuid: UUID) {
|
|
owsAssertDebug(!invitedMembersToPromote.contains(uuid))
|
|
invitedMembersToPromote.append(uuid)
|
|
}
|
|
|
|
public func changeRoleForMember(_ uuid: UUID, role: TSGroupMemberRole) {
|
|
owsAssertDebug(membersToChangeRole[uuid] == nil)
|
|
membersToChangeRole[uuid] = role
|
|
}
|
|
|
|
public func addInvitedMember(_ uuid: UUID, role: TSGroupMemberRole) {
|
|
owsAssertDebug(invitedMembersToAdd[uuid] == nil)
|
|
invitedMembersToAdd[uuid] = role
|
|
}
|
|
|
|
public func setShouldLeaveGroupDeclineInvite() {
|
|
owsAssertDebug(!shouldLeaveGroupDeclineInvite)
|
|
shouldLeaveGroupDeclineInvite = true
|
|
}
|
|
|
|
public func removeInvalidInvite(invalidInvite: InvalidInvite) {
|
|
owsAssertDebug(invalidInvitesToRemove[invalidInvite.userId] == nil)
|
|
invalidInvitesToRemove[invalidInvite.userId] = invalidInvite
|
|
}
|
|
|
|
public func setAccessForMembers(_ value: GroupV2Access) {
|
|
owsAssertDebug(accessForMembers == nil)
|
|
accessForMembers = value
|
|
}
|
|
|
|
public func setAccessForAttributes(_ value: GroupV2Access) {
|
|
owsAssertDebug(accessForAttributes == nil)
|
|
accessForAttributes = value
|
|
}
|
|
|
|
public func setNewDisappearingMessageToken(_ newDisappearingMessageToken: DisappearingMessageToken) {
|
|
owsAssertDebug(self.newDisappearingMessageToken == nil)
|
|
self.newDisappearingMessageToken = newDisappearingMessageToken
|
|
}
|
|
|
|
public func setShouldUpdateLocalProfileKey() {
|
|
owsAssertDebug(!shouldUpdateLocalProfileKey)
|
|
shouldUpdateLocalProfileKey = true
|
|
}
|
|
|
|
public func revokeInvalidInvites() {
|
|
owsAssertDebug(!shouldRevokeInvalidInvites)
|
|
shouldRevokeInvalidInvites = true
|
|
}
|
|
|
|
public func setLinkMode(_ linkMode: GroupsV2LinkMode) {
|
|
owsAssertDebug(accessForAddFromInviteLink == nil)
|
|
owsAssertDebug(inviteLinkPasswordMode == nil)
|
|
|
|
switch linkMode {
|
|
case .disabled:
|
|
accessForAddFromInviteLink = .unsatisfiable
|
|
inviteLinkPasswordMode = .ignore
|
|
case .enabledWithoutApproval, .enabledWithApproval:
|
|
accessForAddFromInviteLink = (linkMode == .enabledWithoutApproval
|
|
? .any
|
|
: .administrator)
|
|
inviteLinkPasswordMode = .ensureValid
|
|
}
|
|
}
|
|
|
|
public func rotateInviteLinkPassword() {
|
|
owsAssertDebug(inviteLinkPasswordMode == nil)
|
|
|
|
inviteLinkPasswordMode = .rotate
|
|
}
|
|
|
|
public func setIsAnnouncementsOnly(_ isAnnouncementsOnly: Bool) {
|
|
owsAssertDebug(self.isAnnouncementsOnly == nil)
|
|
|
|
self.isAnnouncementsOnly = isAnnouncementsOnly
|
|
}
|
|
|
|
// MARK: - Change Protos
|
|
|
|
private typealias ProfileKeyCredentialMap = [UUID: ProfileKeyCredential]
|
|
|
|
// Given the "current" group state, build a change proto that
|
|
// reflects the elements of the "original intent" that are still
|
|
// necessary to perform.
|
|
//
|
|
// See comments on buildGroupChangeProto() below.
|
|
public func buildGroupChangeProto(currentGroupModel: TSGroupModelV2,
|
|
currentDisappearingMessageToken: DisappearingMessageToken) -> Promise<GroupsProtoGroupChangeActions> {
|
|
guard groupId == currentGroupModel.groupId else {
|
|
return Promise(error: OWSAssertionError("Mismatched groupId."))
|
|
}
|
|
guard let groupsV2Impl = groupsV2 as? GroupsV2Impl else {
|
|
return Promise(error: OWSAssertionError("Invalid groupsV2: \(type(of: groupsV2))"))
|
|
}
|
|
guard let localUuid = tsAccountManager.localUuid else {
|
|
return Promise(error: OWSAssertionError("Missing localUuid."))
|
|
}
|
|
|
|
// Note that we're calculating the set of users for whom we need
|
|
// profile key credentials for based on the "original intent".
|
|
// We could slightly optimize by only gathering profile key
|
|
// credentials that we'll actually need to build the change proto.
|
|
//
|
|
// NOTE: We don't (and can't) gather profile key credentials for pending members.
|
|
var newUserUUIDs = Set<UUID>()
|
|
newUserUUIDs.formUnion(membersToAdd.keys)
|
|
newUserUUIDs.formUnion(invitedMembersToPromote)
|
|
// This should be redundant, but we'll also double-check that we have
|
|
// the local profile key credential.
|
|
newUserUUIDs.insert(localUuid)
|
|
let newAddresses = newUserUUIDs.map { SignalServiceAddress(uuid: $0) }
|
|
let addressesForProfileKeyCredentials = newAddresses
|
|
|
|
if isMissingAnnouncementOnlyCapability(currentGroupModel: currentGroupModel,
|
|
newAddresses: newAddresses) {
|
|
return Promise(error: GroupsV2Error.newMemberMissingAnnouncementOnlyCapability)
|
|
}
|
|
|
|
return firstly {
|
|
groupsV2Impl.tryToEnsureProfileKeyCredentials(for: addressesForProfileKeyCredentials,
|
|
ignoreMissingProfiles: false)
|
|
}.then(on: .global()) { (_) -> Promise<ProfileKeyCredentialMap> in
|
|
groupsV2Impl.loadProfileKeyCredentialData(for: Array(newUserUUIDs))
|
|
}.map(on: .global()) { (profileKeyCredentialMap: ProfileKeyCredentialMap) throws -> GroupsProtoGroupChangeActions in
|
|
try self.buildGroupChangeProto(currentGroupModel: currentGroupModel,
|
|
currentDisappearingMessageToken: currentDisappearingMessageToken,
|
|
profileKeyCredentialMap: profileKeyCredentialMap)
|
|
}
|
|
}
|
|
|
|
private func isMissingAnnouncementOnlyCapability(currentGroupModel: TSGroupModelV2,
|
|
newAddresses: [SignalServiceAddress]) -> Bool {
|
|
let shouldPreventNewMembersWithoutAnnouncementOnlyCapability: Bool = {
|
|
if let isAnnouncementsOnly = self.isAnnouncementsOnly {
|
|
return isAnnouncementsOnly
|
|
}
|
|
return currentGroupModel.isAnnouncementsOnly
|
|
}()
|
|
guard shouldPreventNewMembersWithoutAnnouncementOnlyCapability else {
|
|
return false
|
|
}
|
|
return databaseStorage.read { transaction in
|
|
for address in newAddresses {
|
|
guard GroupManager.doesUserHaveAnnouncementOnlyGroupsCapability(address: address,
|
|
transaction: transaction) else {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Given the "current" group state, build a change proto that
|
|
// reflects the elements of the "original intent" that are still
|
|
// necessary to perform.
|
|
//
|
|
// This method builds the actual set of actions _that are still necessary_.
|
|
// Conflicts can occur due to races. This is where we make a best effort to
|
|
// resolve conflicts.
|
|
//
|
|
// Conflict resolution guidelines:
|
|
//
|
|
// * “Orthogonal” changes are resolved by simply retrying.
|
|
// * If you're trying to change the avatar and someone
|
|
// else changes the title, there is no conflict.
|
|
// * Many conflicts can be resolved by “last writer wins”.
|
|
// * E.g. changes to group name or avatar.
|
|
// * We skip identical changes.
|
|
// * If you want to add Alice but Carol has already
|
|
// added Alice, we treat this as redundant.
|
|
// * "Overlapping" changes are not conflicts.
|
|
// * If you want to add (Alice and Bob) but Carol has already
|
|
// added Alice, we convert your intent to just adding Bob.
|
|
// * We skip similar changes when they have similar intent.
|
|
// * If you try to add Alice but Bob has already invited
|
|
// Alice, we treat these as redundant. The intent - to get
|
|
// Alice into the group - is the same.
|
|
// * We skip similar changes when they differ in details.
|
|
// * If you try to add Alice as admin and Bob has already
|
|
// added Alice as a normal member, we treat these as
|
|
// redundant. We could convert your intent into
|
|
// changing Alice's role, but that can confuse the user.
|
|
// * We treat "obsolete" changes as an unresolvable conflict.
|
|
// * If you try to change Alice's role to admin and Bob has
|
|
// already kicked out Alice, we throw
|
|
// GroupsV2Error.conflictingChange.
|
|
//
|
|
// Essentially, our strategy is to "apply any changes that
|
|
// still make sense". If no changes do, we throw
|
|
// GroupsV2Error.redundantChange.
|
|
private func buildGroupChangeProto(currentGroupModel: TSGroupModelV2,
|
|
currentDisappearingMessageToken: DisappearingMessageToken,
|
|
profileKeyCredentialMap: ProfileKeyCredentialMap) throws -> GroupsProtoGroupChangeActions {
|
|
let groupV2Params = try currentGroupModel.groupV2Params()
|
|
|
|
var actionsBuilder = GroupsProtoGroupChangeActions.builder()
|
|
guard let localUuid = tsAccountManager.localUuid else {
|
|
throw OWSAssertionError("Missing localUuid.")
|
|
}
|
|
|
|
let oldRevision = currentGroupModel.revision
|
|
let newRevision = oldRevision + 1
|
|
Logger.verbose("Revision: \(oldRevision) -> \(newRevision)")
|
|
actionsBuilder.setRevision(newRevision)
|
|
|
|
// Track member counts that are updated to reflect each
|
|
// new action.
|
|
var remainingMemberOfAnyKindUuids = Set(currentGroupModel.groupMembership.allMembersOfAnyKind.compactMap { $0.uuid })
|
|
var remainingFullMemberUuids = Set(currentGroupModel.groupMembership.fullMembers.compactMap { $0.uuid })
|
|
var remainingAdminUuids = Set(currentGroupModel.groupMembership.fullMemberAdministrators.compactMap { $0.uuid })
|
|
|
|
var didChange = false
|
|
|
|
if let newTitle = self.newTitle {
|
|
if newTitle == currentGroupModel.groupName {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
let encryptedData = try groupV2Params.encryptGroupName(newTitle)
|
|
guard newTitle.glyphCount <= GroupManager.maxGroupNameGlyphCount else {
|
|
throw OWSAssertionError("groupTitle is too long.")
|
|
}
|
|
guard encryptedData.count <= GroupManager.maxGroupNameEncryptedByteCount else {
|
|
throw OWSAssertionError("Encrypted groupTitle is too long.")
|
|
}
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyTitleAction.builder()
|
|
actionBuilder.setTitle(encryptedData)
|
|
actionsBuilder.setModifyTitle(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
if let newDescriptionText = self.newDescriptionText {
|
|
if newDescriptionText.nilIfEmpty == currentGroupModel.descriptionText?.nilIfEmpty {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
guard newDescriptionText.glyphCount <= GroupManager.maxGroupDescriptionGlyphCount else {
|
|
throw OWSAssertionError("group description is too long.")
|
|
}
|
|
let encryptedData = try groupV2Params.encryptGroupDescription(newDescriptionText)
|
|
guard encryptedData.count <= GroupManager.maxGroupDescriptionEncryptedByteCount else {
|
|
throw OWSAssertionError("Encrypted group description is too long.")
|
|
}
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyDescriptionAction.builder()
|
|
actionBuilder.setDescriptionBytes(encryptedData)
|
|
actionsBuilder.setModifyDescription(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
if shouldUpdateAvatar {
|
|
if newAvatarUrlPath == currentGroupModel.avatarUrlPath {
|
|
// Redundant change, not a conflict.
|
|
owsFailDebug("This should never occur.")
|
|
} else {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyAvatarAction.builder()
|
|
if let avatarUrlPath = newAvatarUrlPath {
|
|
actionBuilder.setAvatar(avatarUrlPath)
|
|
} else {
|
|
// We're clearing the avatar.
|
|
}
|
|
actionsBuilder.setModifyAvatar(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
if let inviteLinkPasswordMode = inviteLinkPasswordMode {
|
|
let newInviteLinkPassword: Data?
|
|
switch inviteLinkPasswordMode {
|
|
case .ignore:
|
|
newInviteLinkPassword = currentGroupModel.inviteLinkPassword
|
|
case .rotate:
|
|
newInviteLinkPassword = GroupManager.generateInviteLinkPasswordV2()
|
|
case .ensureValid:
|
|
if let oldInviteLinkPassword = currentGroupModel.inviteLinkPassword,
|
|
!oldInviteLinkPassword.isEmpty {
|
|
newInviteLinkPassword = oldInviteLinkPassword
|
|
} else {
|
|
newInviteLinkPassword = GroupManager.generateInviteLinkPasswordV2()
|
|
}
|
|
}
|
|
|
|
if newInviteLinkPassword == currentGroupModel.inviteLinkPassword {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyInviteLinkPasswordAction.builder()
|
|
if let inviteLinkPassword = newInviteLinkPassword {
|
|
actionBuilder.setInviteLinkPassword(inviteLinkPassword)
|
|
}
|
|
actionsBuilder.setModifyInviteLinkPassword(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
let currentGroupMembership = currentGroupModel.groupMembership
|
|
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
|
|
}
|
|
if currentGroupMembership.isRequestingMember(uuid) {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsPromoteRequestingMemberAction.builder()
|
|
let userId = try groupV2Params.userId(forUuid: uuid)
|
|
actionBuilder.setUserID(userId)
|
|
actionBuilder.setRole(role.asProtoRole)
|
|
actionsBuilder.addPromoteRequestingMembers(try actionBuilder.build())
|
|
|
|
remainingMemberOfAnyKindUuids.insert(uuid)
|
|
remainingFullMemberUuids.insert(uuid)
|
|
if role == .administrator {
|
|
remainingAdminUuids.insert(uuid)
|
|
}
|
|
} else {
|
|
guard let profileKeyCredential = profileKeyCredentialMap[uuid] else {
|
|
throw OWSAssertionError("Missing profile key credential: \(uuid)")
|
|
}
|
|
var actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder()
|
|
actionBuilder.setAdded(try GroupsV2Protos.buildMemberProto(profileKeyCredential: profileKeyCredential,
|
|
role: role.asProtoRole,
|
|
groupV2Params: groupV2Params))
|
|
actionsBuilder.addAddMembers(try actionBuilder.build())
|
|
|
|
remainingMemberOfAnyKindUuids.insert(uuid)
|
|
remainingFullMemberUuids.insert(uuid)
|
|
if role == .administrator {
|
|
remainingAdminUuids.insert(uuid)
|
|
}
|
|
}
|
|
didChange = true
|
|
}
|
|
|
|
for uuid in self.membersToRemove {
|
|
if currentGroupMembership.isFullMember(uuid) {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeleteMemberAction.builder()
|
|
let userId = try groupV2Params.userId(forUuid: uuid)
|
|
actionBuilder.setDeletedUserID(userId)
|
|
actionsBuilder.addDeleteMembers(try actionBuilder.build())
|
|
didChange = true
|
|
|
|
remainingMemberOfAnyKindUuids.remove(uuid)
|
|
remainingFullMemberUuids.remove(uuid)
|
|
if currentGroupMembership.isFullMemberAndAdministrator(uuid) {
|
|
remainingAdminUuids.remove(uuid)
|
|
}
|
|
} else if currentGroupMembership.isInvitedMember(uuid) {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder()
|
|
let userId = try groupV2Params.userId(forUuid: uuid)
|
|
actionBuilder.setDeletedUserID(userId)
|
|
actionsBuilder.addDeletePendingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
|
|
remainingMemberOfAnyKindUuids.remove(uuid)
|
|
remainingFullMemberUuids.remove(uuid)
|
|
} else if currentGroupMembership.isRequestingMember(uuid) {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeleteRequestingMemberAction.builder()
|
|
let userId = try groupV2Params.userId(forUuid: uuid)
|
|
actionBuilder.setDeletedUserID(userId)
|
|
actionsBuilder.addDeleteRequestingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
|
|
remainingMemberOfAnyKindUuids.remove(uuid)
|
|
remainingFullMemberUuids.remove(uuid)
|
|
} else {
|
|
// Another user has already removed this member or revoked their
|
|
// invitation.
|
|
// Redundant change, not a conflict.
|
|
continue
|
|
}
|
|
}
|
|
|
|
for (uuid, role) in self.invitedMembersToAdd {
|
|
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.
|
|
continue
|
|
}
|
|
|
|
guard remainingMemberOfAnyKindUuids.count <= GroupManager.groupsV2MaxGroupSizeHardLimit else {
|
|
throw GroupsV2Error.tooManyMembers
|
|
}
|
|
|
|
var actionBuilder = GroupsProtoGroupChangeActionsAddPendingMemberAction.builder()
|
|
actionBuilder.setAdded(try GroupsV2Protos.buildPendingMemberProto(uuid: uuid,
|
|
role: role.asProtoRole,
|
|
localUuid: localUuid,
|
|
groupV2Params: groupV2Params))
|
|
actionsBuilder.addAddPendingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
|
|
remainingMemberOfAnyKindUuids.insert(uuid)
|
|
if role == .administrator {
|
|
remainingAdminUuids.insert(uuid)
|
|
}
|
|
}
|
|
|
|
if shouldRevokeInvalidInvites {
|
|
if currentGroupMembership.invalidInvites.count < 1 {
|
|
// Another user has already revoked any invalid invites.
|
|
// We don't treat that as a conflict.
|
|
owsFailDebug("No invalid invites to revoke.")
|
|
}
|
|
for invalidInvite in currentGroupMembership.invalidInvites {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder()
|
|
actionBuilder.setDeletedUserID(invalidInvite.userId)
|
|
actionsBuilder.addDeletePendingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
} else {
|
|
for invalidInvite in invalidInvitesToRemove.values {
|
|
guard currentGroupMembership.hasInvalidInvite(forUserId: invalidInvite.userId) else {
|
|
// Another user has already removed this invite.
|
|
// We don't treat that as a conflict.
|
|
continue
|
|
}
|
|
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder()
|
|
actionBuilder.setDeletedUserID(invalidInvite.userId)
|
|
actionsBuilder.addDeletePendingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
for (uuid, newRole) in self.membersToChangeRole {
|
|
guard currentGroupMembership.isFullMember(uuid) else {
|
|
// User is no longer a member.
|
|
throw GroupsV2Error.conflictingChange
|
|
}
|
|
let currentRole = currentGroupMembership.role(for: uuid)
|
|
guard currentRole != newRole else {
|
|
// Another user has already modifed the role of this member.
|
|
// We don't treat that as a conflict.
|
|
continue
|
|
}
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyMemberRoleAction.builder()
|
|
let userId = try groupV2Params.userId(forUuid: uuid)
|
|
actionBuilder.setUserID(userId)
|
|
actionBuilder.setRole(newRole.asProtoRole)
|
|
actionsBuilder.addModifyMemberRoles(try actionBuilder.build())
|
|
didChange = true
|
|
|
|
if currentRole == .administrator {
|
|
remainingAdminUuids.remove(uuid)
|
|
} else if newRole == .administrator {
|
|
remainingAdminUuids.insert(uuid)
|
|
}
|
|
}
|
|
|
|
let currentAccess = currentGroupModel.access
|
|
if let access = self.accessForMembers {
|
|
if currentAccess.members == access {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyMembersAccessControlAction.builder()
|
|
actionBuilder.setMembersAccess(access.protoAccess)
|
|
actionsBuilder.setModifyMemberAccess(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
if let access = self.accessForAttributes {
|
|
if currentAccess.attributes == access {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyAttributesAccessControlAction.builder()
|
|
actionBuilder.setAttributesAccess(access.protoAccess)
|
|
actionsBuilder.setModifyAttributesAccess(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
var accessForAddFromInviteLink = self.accessForAddFromInviteLink
|
|
if currentGroupMembership.allMembersOfAnyKind.count == 1 &&
|
|
currentGroupMembership.isFullMemberAndAdministrator(localUuid) &&
|
|
self.shouldLeaveGroupDeclineInvite {
|
|
// If we're the last admin to leave the group,
|
|
// disable the group invite link.
|
|
accessForAddFromInviteLink = .unsatisfiable
|
|
}
|
|
|
|
if let access = accessForAddFromInviteLink {
|
|
if currentAccess.addFromInviteLink == access {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyAddFromInviteLinkAccessControlAction.builder()
|
|
actionBuilder.setAddFromInviteLinkAccess(access.protoAccess)
|
|
actionsBuilder.setModifyAddFromInviteLinkAccess(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
for uuid in invitedMembersToPromote {
|
|
// Check that pending member is still invited.
|
|
guard currentGroupMembership.isInvitedMember(uuid) else {
|
|
throw GroupsV2Error.redundantChange
|
|
}
|
|
guard let profileKeyCredential = profileKeyCredentialMap[uuid] else {
|
|
throw OWSAssertionError("Missing profile key credential: \(uuid)")
|
|
}
|
|
var actionBuilder = GroupsProtoGroupChangeActionsPromotePendingMemberAction.builder()
|
|
actionBuilder.setPresentation(try GroupsV2Protos.presentationData(profileKeyCredential: profileKeyCredential,
|
|
groupV2Params: groupV2Params))
|
|
actionsBuilder.addPromotePendingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
|
|
remainingMemberOfAnyKindUuids.insert(uuid)
|
|
remainingFullMemberUuids.insert(uuid)
|
|
}
|
|
|
|
if self.shouldLeaveGroupDeclineInvite {
|
|
let canLeaveGroup = GroupManager.canLocalUserLeaveGroupWithoutChoosingNewAdmin(localUuid: localUuid,
|
|
remainingFullMemberUuids: remainingFullMemberUuids,
|
|
remainingAdminUuids: remainingAdminUuids)
|
|
guard canLeaveGroup else {
|
|
// This could happen if the last two admins leave at the same time
|
|
// and race.
|
|
throw GroupsV2Error.lastAdminCantLeaveGroup
|
|
}
|
|
|
|
// Check that we are still invited or in group.
|
|
if currentGroupMembership.isInvitedMember(localUuid) {
|
|
// Decline invite
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder()
|
|
let localUserId = try groupV2Params.userId(forUuid: localUuid)
|
|
actionBuilder.setDeletedUserID(localUserId)
|
|
actionsBuilder.addDeletePendingMembers(try actionBuilder.build())
|
|
didChange = true
|
|
} else if currentGroupMembership.isFullMember(localUuid) {
|
|
// Leave group
|
|
var actionBuilder = GroupsProtoGroupChangeActionsDeleteMemberAction.builder()
|
|
let localUserId = try groupV2Params.userId(forUuid: localUuid)
|
|
actionBuilder.setDeletedUserID(localUserId)
|
|
actionsBuilder.addDeleteMembers(try actionBuilder.build())
|
|
didChange = true
|
|
} else {
|
|
// Redundant change, not a conflict.
|
|
}
|
|
}
|
|
|
|
if let newDisappearingMessageToken = self.newDisappearingMessageToken {
|
|
if newDisappearingMessageToken == currentDisappearingMessageToken {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
let encryptedTimerData = try groupV2Params.encryptDisappearingMessagesTimer(newDisappearingMessageToken)
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyDisappearingMessagesTimerAction.builder()
|
|
actionBuilder.setTimer(encryptedTimerData)
|
|
actionsBuilder.setModifyDisappearingMessagesTimer(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
if let isAnnouncementsOnly = self.isAnnouncementsOnly {
|
|
if isAnnouncementsOnly == currentGroupModel.isAnnouncementsOnly {
|
|
// Redundant change, not a conflict.
|
|
} else {
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyAnnouncementsOnlyAction.builder()
|
|
actionBuilder.setAnnouncementsOnly(isAnnouncementsOnly)
|
|
actionsBuilder.setModifyAnnouncementsOnly(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
}
|
|
|
|
if shouldUpdateLocalProfileKey {
|
|
guard let profileKeyCredential = profileKeyCredentialMap[localUuid] else {
|
|
throw OWSAssertionError("Missing profile key credential: \(localUuid)")
|
|
}
|
|
var actionBuilder = GroupsProtoGroupChangeActionsModifyMemberProfileKeyAction.builder()
|
|
actionBuilder.setPresentation(try GroupsV2Protos.presentationData(profileKeyCredential: profileKeyCredential,
|
|
groupV2Params: groupV2Params))
|
|
actionsBuilder.addModifyMemberProfileKeys(try actionBuilder.build())
|
|
didChange = true
|
|
}
|
|
|
|
guard didChange else {
|
|
throw GroupsV2Error.redundantChange
|
|
}
|
|
|
|
let actionsProto = try actionsBuilder.build()
|
|
// TODO: Remove this logging once we're resolved the "empty update" issue.
|
|
if DebugFlags.internalLogging {
|
|
Logger.info("Updating group: \(actionsProto.debugDescription).")
|
|
} else {
|
|
Logger.info("Updating group.")
|
|
}
|
|
return actionsProto
|
|
}
|
|
}
|