TM-SGNL-iOS/SignalServiceKit/Groups/GroupManager.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1516 lines
61 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
// * The "local" methods are used in response to the local user's interactions.
// * The "remote" methods are used in response to remote activity (incoming messages,
// sync transcripts, group syncs, etc.).
@objc
public class GroupManager: NSObject {
// Never instantiate this class.
private override init() {}
// MARK: -
// GroupsV2 TODO: Finalize this value with the designers.
public static let groupUpdateTimeoutDuration: TimeInterval = 30
public static var groupsV2MaxGroupSizeRecommended: UInt {
return RemoteConfig.current.groupsV2MaxGroupSizeRecommended
}
public static var groupsV2MaxGroupSizeHardLimit: UInt {
return RemoteConfig.current.groupsV2MaxGroupSizeHardLimit
}
public static let maxGroupNameEncryptedByteCount: Int = 1024
public static let maxGroupNameGlyphCount: Int = 32
public static let maxGroupDescriptionEncryptedByteCount: Int = 8192
public static let maxGroupDescriptionGlyphCount: Int = 480
// Epoch 1: Group Links
// Epoch 2: Group Description
// Epoch 3: Announcement-Only Groups
// Epoch 4: Banned Members
// Epoch 5: Promote pending PNI members
public static let changeProtoEpoch: UInt32 = 5
// This matches kOversizeTextMessageSizeThreshold.
public static let maxEmbeddedChangeProtoLength: UInt = 2 * 1024
// MARK: - Group IDs
static func groupIdLength(for groupsVersion: GroupsVersion) -> UInt {
switch groupsVersion {
case .V1:
return kGroupIdLengthV1
case .V2:
return kGroupIdLengthV2
}
}
@objc
public static func isV1GroupId(_ groupId: Data) -> Bool {
groupId.count == groupIdLength(for: .V1)
}
@objc
public static func isV2GroupId(_ groupId: Data) -> Bool {
groupId.count == groupIdLength(for: .V2)
}
@objc
public static func isValidGroupId(_ groupId: Data, groupsVersion: GroupsVersion) -> Bool {
let expectedLength = groupIdLength(for: groupsVersion)
guard groupId.count == expectedLength else {
owsFailDebug("Invalid groupId: \(groupId.count) != \(expectedLength)")
return false
}
return true
}
@objc
public static func isValidGroupIdOfAnyKind(_ groupId: Data) -> Bool {
return isV1GroupId(groupId) || isV2GroupId(groupId)
}
// MARK: -
public static func canLocalUserLeaveGroupWithoutChoosingNewAdmin(
localAci: Aci,
groupMembership: GroupMembership
) -> Bool {
let fullMembers = Set(groupMembership.fullMembers.compactMap { $0.serviceId as? Aci })
let fullMemberAdmins = Set(groupMembership.fullMemberAdministrators.compactMap { $0.serviceId as? Aci })
return canLocalUserLeaveGroupWithoutChoosingNewAdmin(
localAci: localAci,
fullMembers: fullMembers,
admins: fullMemberAdmins
)
}
public static func canLocalUserLeaveGroupWithoutChoosingNewAdmin(
localAci: Aci,
fullMembers: Set<Aci>,
admins: Set<Aci>
) -> Bool {
// If the current user is the only admin and they're not the only member of
// the group, then they must select a new admin.
if Set([localAci]) == admins && Set([localAci]) != fullMembers {
return false
}
return true
}
// MARK: - Group Models
@objc
public static func fakeGroupModel(groupId: Data) -> TSGroupModel? {
do {
var builder = TSGroupModelBuilder()
builder.groupId = groupId
if GroupManager.isV1GroupId(groupId) {
builder.groupsVersion = .V1
} else if GroupManager.isV2GroupId(groupId) {
builder.groupsVersion = .V2
} else {
throw OWSAssertionError("Invalid group id: \(groupId).")
}
return try builder.build()
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
/// Confirms that a given address supports V2 groups.
///
/// This check will succeed for any currently-registered users. It is
/// possible that contacts dating from the V1 group era will fail this
/// check.
///
/// This method should only be used in contexts in which it is possible we
/// are dealing with very old historical contacts, and need to filter them
/// for those that are GV2-compatible.
public static func doesUserSupportGroupsV2(address: SignalServiceAddress) -> Bool {
guard address.isValid else {
Logger.warn("Invalid address: \(address).")
return false
}
guard address.serviceId != nil else {
Logger.warn("Member without UUID.")
return false
}
return true
}
// MARK: - Create New Group
/// Create a new group locally, and upload it to the service.
///
/// - Parameter groupId
/// A fixed group ID. Intended for use exclusively in tests.
public static func localCreateNewGroup(
members membersParam: [SignalServiceAddress],
groupId: Data? = nil,
name: String? = nil,
avatarData: Data? = nil,
disappearingMessageToken: DisappearingMessageToken,
newGroupSeed: NewGroupSeed? = nil,
shouldSendMessage: Bool
) async throws -> TSGroupThread {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
throw OWSAssertionError("Missing localIdentifiers.")
}
try await ensureLocalProfileHasCommitmentIfNecessary()
// Build member list.
//
// The group creator is an administrator;
// the other members are normal users.
var builder = GroupMembership.Builder()
builder.addFullMembers(Set(membersParam), role: .normal)
builder.remove(localIdentifiers.aci)
builder.addFullMember(localIdentifiers.aci, role: .administrator)
let initialGroupMembership = builder.build()
// Try to get profile key credentials for all group members, since
// we need them to fully add (rather than merely inviting) members.
try await SSKEnvironment.shared.groupsV2Ref.tryToFetchProfileKeyCredentials(
for: initialGroupMembership.allMembersOfAnyKind.compactMap { $0.serviceId as? Aci },
ignoreMissingProfiles: false,
forceRefresh: false
)
let groupAccess = GroupAccess.defaultForV2
let separatedGroupMembership = SSKEnvironment.shared.databaseStorageRef.read { tx in
// Before we create the group, we need to separate out the
// pending and full members.
return separateInvitedMembersForNewGroup(
withMembership: initialGroupMembership,
transaction: tx
)
}
guard separatedGroupMembership.isFullMember(localIdentifiers.aci) else {
throw OWSAssertionError("Local ACI is missing from group membership.")
}
// The avatar URL path will be filled in later.
var groupModelBuilder = TSGroupModelBuilder()
groupModelBuilder.groupId = groupId
groupModelBuilder.name = name
groupModelBuilder.avatarData = avatarData
groupModelBuilder.avatarUrlPath = nil
groupModelBuilder.groupMembership = separatedGroupMembership
groupModelBuilder.groupAccess = groupAccess
groupModelBuilder.newGroupSeed = newGroupSeed
var proposedGroupModel = try groupModelBuilder.buildAsV2()
if let avatarData = avatarData {
// Upload avatar.
let avatarUrlPath = try await SSKEnvironment.shared.groupsV2Ref.uploadGroupAvatar(
avatarData: avatarData,
groupSecretParams: try proposedGroupModel.secretParams()
)
// Fill in the avatarUrl on the group model.
var builder = proposedGroupModel.asBuilder
builder.avatarUrlPath = avatarUrlPath
proposedGroupModel = try builder.buildAsV2()
}
let snapshotResponse = try await SSKEnvironment.shared.groupsV2Ref.createNewGroupOnService(
groupModel: proposedGroupModel,
disappearingMessageToken: disappearingMessageToken
)
let thread = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
let builder = try TSGroupModelBuilder.builderForSnapshot(
groupV2Snapshot: snapshotResponse.groupSnapshot,
transaction: tx
)
let groupModel = try builder.buildAsV2()
let thread = self.insertGroupThreadInDatabaseAndCreateInfoMessage(
groupModel: groupModel,
disappearingMessageToken: disappearingMessageToken,
groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: tx
)
SSKEnvironment.shared.profileManagerRef.addThread(
toProfileWhitelist: thread,
userProfileWriter: .localUser,
transaction: tx
)
return thread
}
if shouldSendMessage {
await sendDurableNewGroupMessage(forThread: thread)
}
return thread
}
// Separates pending and non-pending members.
// We cannot add non-pending members unless:
//
// * We know their profile key.
// * We have a profile key credential for them.
private static func separateInvitedMembersForNewGroup(
withMembership newGroupMembership: GroupMembership,
transaction tx: SDSAnyReadTransaction
) -> GroupMembership {
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aci else {
owsFailDebug("Missing localAci.")
return newGroupMembership
}
var builder = GroupMembership.Builder()
let newMembers = newGroupMembership.allMembersOfAnyKind
// We only need to separate new members.
for address in newMembers {
// We must call this _after_ we try to fetch profile key credentials for
// all members.
let hasCredential = SSKEnvironment.shared.groupsV2Ref.hasProfileKeyCredential(for: address, transaction: tx)
guard let role = newGroupMembership.role(for: address) else {
owsFailDebug("Missing role: \(address)")
continue
}
guard let serviceId = address.serviceId else {
owsFailDebug("Missing serviceId.")
continue
}
if let aci = serviceId as? Aci, hasCredential {
builder.addFullMember(aci, role: role)
} else {
builder.addInvitedMember(serviceId, role: role, addedByAci: localAci)
}
}
return builder.build()
}
// MARK: - Tests
#if TESTABLE_BUILD
/// Create a group for testing purposes.
///
/// - Parameter shouldInsertInfoMessage
/// Whether an info message describing this group's creation should be
/// inserted in the to-be-created thread corresponding to the group. If
/// `true`, the local user must be a member of the group.
public static func createGroupForTests(members: [SignalServiceAddress],
shouldInsertInfoMessage: Bool = false,
name: String? = nil,
descriptionText: String? = nil,
avatarData: Data? = nil,
groupId: Data? = nil,
transaction: SDSAnyWriteTransaction) throws -> TSGroupThread {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else {
throw OWSAssertionError("Missing localIdentifiers.")
}
// GroupsV2 TODO: Elaborate tests to include admins, pending members, etc.
// GroupsV2 TODO: Let tests specify access levels.
// GroupsV2 TODO: Fill in avatarUrlPath when we test v2 groups.
var builder = TSGroupModelBuilder()
builder.groupId = groupId
builder.name = name
builder.descriptionText = descriptionText
builder.avatarData = avatarData
builder.avatarUrlPath = nil
builder.groupMembership = GroupMembership(membersForTest: members)
builder.groupAccess = .defaultForV2
let groupModel = try builder.buildAsV2()
// Just create it in the database, don't create it on the service.
return try remoteUpsertExistingGroupForTests(
groupModel: groupModel,
disappearingMessageToken: nil,
groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
infoMessagePolicy: shouldInsertInfoMessage ? .always : .never,
localIdentifiers: localIdentifiers,
transaction: transaction
)
}
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
private static func remoteUpsertExistingGroupForTests(
groupModel: TSGroupModelV2,
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSource: GroupUpdateSource,
infoMessagePolicy: InfoMessagePolicy = .always,
localIdentifiers: LocalIdentifiers,
transaction: SDSAnyWriteTransaction
) throws -> TSGroupThread {
return try self.tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage(
newGroupModel: groupModel,
newDisappearingMessageToken: disappearingMessageToken,
newlyLearnedPniToAciAssociations: [:],
groupUpdateSource: groupUpdateSource,
didAddLocalUserToV2Group: false,
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .unreportable,
transaction: transaction
)
}
#endif
// MARK: - Disappearing Messages for group threads
private static func updateDisappearingMessageConfiguration(
newToken: DisappearingMessageToken,
groupThread: TSGroupThread,
tx: SDSAnyWriteTransaction
) -> DisappearingMessagesConfigurationStore.SetTokenResult {
let setTokenResult = DependenciesBridge.shared.disappearingMessagesConfigurationStore
.set(token: newToken, for: groupThread, tx: tx.asV2Write)
if setTokenResult.newConfiguration != setTokenResult.oldConfiguration {
SSKEnvironment.shared.databaseStorageRef.touch(thread: groupThread, shouldReindex: false, transaction: tx)
}
return setTokenResult
}
// MARK: - Disappearing Messages for contact threads (for whatever reason, historically part of GroupManager)
public static func remoteUpdateDisappearingMessages(
contactThread: TSContactThread,
disappearingMessageToken: VersionedDisappearingMessageToken,
changeAuthor: Aci?,
localIdentifiers: LocalIdentifiers,
transaction: SDSAnyWriteTransaction
) {
_ = self.updateDisappearingMessagesInDatabaseAndCreateMessages(
newToken: disappearingMessageToken,
contactThread: contactThread,
changeAuthor: changeAuthor,
localIdentifiers: localIdentifiers,
transaction: transaction
)
}
public static func localUpdateDisappearingMessageToken(
_ disappearingMessageToken: VersionedDisappearingMessageToken,
inContactThread contactThread: TSContactThread,
tx: SDSAnyWriteTransaction
) {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read) else {
owsFailDebug("Not registered.")
return
}
let updateResult = self.updateDisappearingMessagesInDatabaseAndCreateMessages(
newToken: disappearingMessageToken,
contactThread: contactThread,
changeAuthor: localIdentifiers.aci,
localIdentifiers: localIdentifiers,
transaction: tx
)
self.sendDisappearingMessagesConfigurationMessage(
updateResult: updateResult,
contactThread: contactThread,
transaction: tx
)
}
private static func updateDisappearingMessagesInDatabaseAndCreateMessages(
newToken: VersionedDisappearingMessageToken,
contactThread: TSContactThread,
changeAuthor: Aci?,
localIdentifiers: LocalIdentifiers,
transaction: SDSAnyWriteTransaction
) -> DisappearingMessagesConfigurationStore.SetTokenResult {
let result = DependenciesBridge.shared.disappearingMessagesConfigurationStore
.set(
token: newToken,
for: .thread(contactThread),
tx: transaction.asV2Write
)
// Skip redundant updates.
if result.newConfiguration != result.oldConfiguration {
let remoteContactName: String? = {
if
let changeAuthor,
changeAuthor != localIdentifiers.aci
{
return SSKEnvironment.shared.contactManagerRef.displayName(
for: SignalServiceAddress(changeAuthor),
tx: transaction
).resolvedValue()
}
return nil
}()
let infoMessage = OWSDisappearingConfigurationUpdateInfoMessage(
contactThread: contactThread,
timestamp: MessageTimestampGenerator.sharedInstance.generateTimestamp(),
isConfigurationEnabled: result.newConfiguration.isEnabled,
configurationDurationSeconds: result.newConfiguration.durationSeconds,
createdByRemoteName: remoteContactName
)
infoMessage.anyInsert(transaction: transaction)
SSKEnvironment.shared.databaseStorageRef.touch(thread: contactThread, shouldReindex: false, transaction: transaction)
}
return result
}
private static func sendDisappearingMessagesConfigurationMessage(
updateResult: DisappearingMessagesConfigurationStore.SetTokenResult,
contactThread: TSContactThread,
transaction: SDSAnyWriteTransaction
) {
guard updateResult.newConfiguration != updateResult.oldConfiguration else {
// The update was redundant, don't send an update message.
return
}
let newConfiguration = updateResult.newConfiguration
let message = OWSDisappearingMessagesConfigurationMessage(
configuration: newConfiguration,
thread: contactThread,
transaction: transaction
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: message
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
}
// MARK: - Accept Invites
public static func localAcceptInviteToGroupV2(
groupModel: TSGroupModelV2,
waitForMessageProcessing: Bool = false
) async throws {
if waitForMessageProcessing {
try await GroupManager.waitForMessageFetchingAndProcessingWithTimeout(description: "Accept invite")
}
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
SSKEnvironment.shared.profileManagerRef.addGroupId(
toProfileWhitelist: groupModel.groupId,
userProfileWriter: .localUser,
transaction: transaction
)
}
_ = try await updateGroupV2(
groupModel: groupModel,
description: "Accept invite"
) { groupChangeSet in
groupChangeSet.setLocalShouldAcceptInvite()
}
}
// MARK: - Leave Group / Decline Invite
public static func localLeaveGroupOrDeclineInvite(
groupThread: TSGroupThread,
replacementAdminAci: Aci? = nil,
waitForMessageProcessing: Bool = false,
tx: SDSAnyWriteTransaction
) -> Promise<TSGroupThread> {
return SSKEnvironment.shared.localUserLeaveGroupJobQueueRef.addJob(
groupThread: groupThread,
replacementAdminAci: replacementAdminAci,
waitForMessageProcessing: waitForMessageProcessing,
tx: tx
)
}
@objc
public static func leaveGroupOrDeclineInviteAsyncWithoutUI(groupThread: TSGroupThread,
transaction: SDSAnyWriteTransaction,
success: (() -> Void)?) {
guard groupThread.isLocalUserMemberOfAnyKind else {
owsFailDebug("unexpectedly trying to leave group for which we're not a member.")
return
}
transaction.addAsyncCompletionOffMain {
firstly {
SSKEnvironment.shared.databaseStorageRef.write(.promise) { transaction in
self.localLeaveGroupOrDeclineInvite(groupThread: groupThread, tx: transaction).asVoid()
}
}.done { _ in
success?()
}.catch { error in
owsFailDebug("Leave group failed: \(error)")
}
}
}
// MARK: - Remove From Group / Revoke Invite
public static func removeFromGroupOrRevokeInviteV2(
groupModel: TSGroupModelV2,
serviceIds: [ServiceId]
) async throws -> TSGroupThread {
return try await updateGroupV2(groupModel: groupModel, description: "Remove from group or revoke invite") { groupChangeSet in
for serviceId in serviceIds {
owsAssertDebug(!groupModel.groupMembership.isRequestingMember(serviceId))
groupChangeSet.removeMember(serviceId)
// Do not ban when revoking an invite
if let aci = serviceId as? Aci, !groupModel.groupMembership.isInvitedMember(serviceId) {
groupChangeSet.addBannedMember(aci)
}
}
}
}
public static func revokeInvalidInvites(groupModel: TSGroupModelV2) async throws -> TSGroupThread {
return try await updateGroupV2(groupModel: groupModel, description: "Revoke invalid invites") { groupChangeSet in
groupChangeSet.revokeInvalidInvites()
}
}
// MARK: - Change Member Role
public static func changeMemberRoleV2(
groupModel: TSGroupModelV2,
aci: Aci,
role: TSGroupMemberRole
) async throws -> TSGroupThread {
return try await updateGroupV2(groupModel: groupModel, description: "Change member role") { groupChangeSet in
groupChangeSet.changeRoleForMember(aci, role: role)
}
}
// MARK: - Change Group Access
public static func changeGroupAttributesAccessV2(groupModel: TSGroupModelV2, access: GroupV2Access) async throws {
_ = try await updateGroupV2(groupModel: groupModel, description: "Change group attributes access") { groupChangeSet in
groupChangeSet.setAccessForAttributes(access)
}
}
public static func changeGroupMembershipAccessV2(groupModel: TSGroupModelV2, access: GroupV2Access) async throws {
_ = try await updateGroupV2(groupModel: groupModel, description: "Change group membership access") { groupChangeSet in
groupChangeSet.setAccessForMembers(access)
}
}
// MARK: - Group Links
public static func updateLinkModeV2(groupModel: TSGroupModelV2, linkMode: GroupsV2LinkMode) async throws -> TSGroupThread {
return try await updateGroupV2(groupModel: groupModel, description: "Change group link mode") { groupChangeSet in
groupChangeSet.setLinkMode(linkMode)
}
}
public static func resetLinkV2(groupModel: TSGroupModelV2) async throws -> TSGroupThread {
return try await updateGroupV2(groupModel: groupModel, description: "Rotate invite link password") { groupChangeSet in
groupChangeSet.rotateInviteLinkPassword()
}
}
public static let inviteLinkPasswordLengthV2: UInt = 16
public static func generateInviteLinkPasswordV2() -> Data {
Randomness.generateRandomBytes(inviteLinkPasswordLengthV2)
}
public static func isPossibleGroupInviteLink(_ url: URL) -> Bool {
let possibleHosts: [String]
if url.scheme == "https" {
possibleHosts = ["signal.group"]
} else if url.scheme == "sgnl" {
possibleHosts = ["signal.group", "joingroup"]
} else {
return false
}
guard let host = url.host else {
return false
}
return possibleHosts.contains(host)
}
public static func joinGroupViaInviteLink(
groupId: Data,
groupSecretParams: GroupSecretParams,
inviteLinkPassword: Data,
groupInviteLinkPreview: GroupInviteLinkPreview,
avatarData: Data?
) async throws {
try await ensureLocalProfileHasCommitmentIfNecessary()
try await SSKEnvironment.shared.groupsV2Ref.joinGroupViaInviteLink(
groupId: groupId,
groupSecretParams: groupSecretParams,
inviteLinkPassword: inviteLinkPassword,
groupInviteLinkPreview: groupInviteLinkPreview,
avatarData: avatarData
)
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
SSKEnvironment.shared.profileManagerRef.addGroupId(
toProfileWhitelist: groupId,
userProfileWriter: .localUser,
transaction: transaction
)
}
}
public static func acceptOrDenyMemberRequestsV2(
groupModel: TSGroupModelV2,
aci: Aci,
shouldAccept: Bool
) async throws -> TSGroupThread {
let description = (shouldAccept ? "Accept group member request" : "Deny group member request")
return try await updateGroupV2(groupModel: groupModel, description: description) { groupChangeSet in
if shouldAccept {
groupChangeSet.addMember(aci, role: .`normal`)
} else {
groupChangeSet.removeMember(aci)
groupChangeSet.addBannedMember(aci)
}
}
}
public static func cancelRequestToJoin(groupModel: TSGroupModelV2) async throws -> TSGroupThread {
let description = "Cancel Request to Join"
return try await Promise.wrapAsync {
try await SSKEnvironment.shared.groupsV2Ref.cancelRequestToJoin(groupModel: groupModel)
}.timeout(seconds: Self.groupUpdateTimeoutDuration, description: description) {
return GroupsV2Error.timeout
}.awaitable()
}
public static func cachedGroupInviteLinkPreview(groupInviteLinkInfo: GroupInviteLinkInfo) -> GroupInviteLinkPreview? {
do {
let groupContextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: groupInviteLinkInfo.masterKey)
return SSKEnvironment.shared.groupsV2Ref.cachedGroupInviteLinkPreview(groupSecretParams: groupContextInfo.groupSecretParams)
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
// MARK: - Announcements
public static func setIsAnnouncementsOnly(groupModel: TSGroupModelV2, isAnnouncementsOnly: Bool) async throws {
_ = try await updateGroupV2(groupModel: groupModel, description: "Update isAnnouncementsOnly") { groupChangeSet in
groupChangeSet.setIsAnnouncementsOnly(isAnnouncementsOnly)
}
}
// MARK: - Local profile key
public static func updateLocalProfileKey(groupModel: TSGroupModelV2) async throws -> TSGroupThread {
return try await updateGroupV2(groupModel: groupModel, description: "Update local profile key") { changes in
changes.setShouldUpdateLocalProfileKey()
}
}
// MARK: - Removed from Group or Invite Revoked
public static func handleNotInGroup(groupId: Data, transaction: SDSAnyWriteTransaction) {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else {
owsFailDebug("Missing localIdentifiers.")
return
}
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
// Local user may have just deleted the thread via the UI.
// Or we maybe be trying to restore a group from storage service
// that we are no longer a member of.
Logger.warn("Missing group in database.")
return
}
let groupModel = groupThread.groupModel
let removeLocalUserBlock: (SDSAnyWriteTransaction) -> Void = { transaction in
// Remove local user from group.
// We do _not_ bump the revision number since this (unlike all other
// changes to group state) is inferred from a 403. This is fine; if
// we're ever re-added to the group the groups v2 machinery will
// recover.
var groupMembershipBuilder = groupModel.groupMembership.asBuilder
groupMembershipBuilder.remove(localIdentifiers.aci)
var groupModelBuilder = groupModel.asBuilder
do {
groupModelBuilder.groupMembership = groupMembershipBuilder.build()
let newGroupModel = try groupModelBuilder.build()
// groupUpdateSource is unknown because we don't (and can't) know who removed
// us or revoked our invite.
//
// newDisappearingMessageToken is nil because we don't want to change DM
// state.
_ = try updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
newGroupModel: newGroupModel,
newDisappearingMessageToken: nil,
newlyLearnedPniToAciAssociations: [:],
groupUpdateSource: .unknown,
infoMessagePolicy: .always,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: transaction
)
} catch {
owsFailDebug("Error: \(error)")
}
}
if
let groupModelV2 = groupModel as? TSGroupModelV2,
groupModelV2.isJoinRequestPlaceholder
{
Logger.warn("Ignoring 403 for placeholder group.")
Task {
try? await SSKEnvironment.shared.groupsV2Ref.tryToUpdatePlaceholderGroupModelUsingInviteLinkPreview(
groupModel: groupModelV2,
removeLocalUserBlock: removeLocalUserBlock
)
}
} else {
removeLocalUserBlock(transaction)
}
}
// MARK: - Messages
public static func sendGroupUpdateMessage(thread: TSGroupThread, groupChangeProtoData: Data? = nil) async {
guard thread.isGroupV2Thread else {
owsFail("[GV1] Should be impossible to send V1 group messages!")
}
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let message = OutgoingGroupUpdateMessage(
in: thread,
groupMetaMessage: .update,
expiresInSeconds: dmConfigurationStore.durationSeconds(for: thread, tx: transaction.asV2Read),
groupChangeProtoData: groupChangeProtoData,
additionalRecipients: Self.invitedMembers(in: thread),
transaction: transaction
)
// "changeActionsProtoData" is _not_ an attachment, it is just put on
// the outgoing proto directly.
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: message
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
}
}
private static func sendDurableNewGroupMessage(forThread thread: TSGroupThread) async {
guard thread.isGroupV2Thread else {
owsFail("[GV1] Should be impossible to send V1 group messages!")
}
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let message = OutgoingGroupUpdateMessage(
in: thread,
groupMetaMessage: .new,
expiresInSeconds: dmConfigurationStore.durationSeconds(for: thread, tx: tx.asV2Read),
additionalRecipients: Self.invitedMembers(in: thread),
transaction: tx
)
// "changeActionsProtoData" is _not_ an attachment, it is just put on
// the outgoing proto directly.
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: message
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx)
}
}
private static func invitedMembers(in thread: TSGroupThread) -> Set<SignalServiceAddress> {
thread.groupModel.groupMembership.invitedMembers.filter { doesUserSupportGroupsV2(address: $0) }
}
private static func invitedOrRequestedMembers(in thread: TSGroupThread) -> Set<SignalServiceAddress> {
thread.groupModel.groupMembership.invitedOrRequestMembers.filter { doesUserSupportGroupsV2(address: $0) }
}
@objc
public static func shouldMessageHaveAdditionalRecipients(_ message: TSOutgoingMessage,
groupThread: TSGroupThread) -> Bool {
guard groupThread.groupModel.groupsVersion == .V2 else {
return false
}
switch message.groupMetaMessage {
case .update, .new:
return true
default:
return false
}
}
// MARK: - Group Database
@objc
public enum InfoMessagePolicy: UInt {
case always
case insertsOnly
case updatesOnly
case never
}
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
public static func insertGroupThreadInDatabaseAndCreateInfoMessage(
groupModel: TSGroupModelV2,
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSource: GroupUpdateSource,
infoMessagePolicy: InfoMessagePolicy = .always,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
transaction: SDSAnyWriteTransaction
) -> TSGroupThread {
if let groupThread = TSGroupThread.fetch(groupId: groupModel.groupId, transaction: transaction) {
owsFail("Inserting existing group thread: \(groupThread.uniqueId).")
}
let groupThread = DependenciesBridge.shared.threadStore.createGroupThread(
groupModel: groupModel, tx: transaction.asV2Write
)
let newDisappearingMessageToken = disappearingMessageToken ?? DisappearingMessageToken.disabledToken
_ = updateDisappearingMessageConfiguration(
newToken: newDisappearingMessageToken,
groupThread: groupThread,
tx: transaction
)
autoWhitelistGroupIfNecessary(
oldGroupModel: nil,
newGroupModel: groupModel,
groupUpdateSource: groupUpdateSource,
localIdentifiers: localIdentifiers,
tx: transaction
)
switch infoMessagePolicy {
case .always, .insertsOnly:
insertGroupUpdateInfoMessageForNewGroup(
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
groupThread: groupThread,
groupModel: groupModel,
disappearingMessageToken: newDisappearingMessageToken,
groupUpdateSource: groupUpdateSource,
transaction: transaction
)
default:
break
}
notifyStorageServiceOfInsertedGroup(groupModel: groupModel,
transaction: transaction)
return groupThread
}
/// Update persisted group-related state for the provided models, or insert
/// it if this group does not already exist. If appropriate, inserts an info
/// message into the group thread describing what has changed about the
/// group.
///
/// - Parameter newlyLearnedPniToAciAssociations
/// Associations between PNIs and ACIs that were learned as a result of this
/// group update.
public static func tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage(
newGroupModel: TSGroupModelV2,
newDisappearingMessageToken: DisappearingMessageToken?,
newlyLearnedPniToAciAssociations: [Pni: Aci],
groupUpdateSource: GroupUpdateSource,
didAddLocalUserToV2Group: Bool,
infoMessagePolicy: InfoMessagePolicy = .always,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
transaction: SDSAnyWriteTransaction
) throws -> TSGroupThread {
let threadId = TSGroupThread.threadId(forGroupId: newGroupModel.groupId, transaction: transaction)
if TSGroupThread.anyExists(uniqueId: threadId, transaction: transaction) {
return try updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
newGroupModel: newGroupModel,
newDisappearingMessageToken: newDisappearingMessageToken,
newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations,
groupUpdateSource: groupUpdateSource,
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
transaction: transaction
)
} else {
/// We only want to attribute the author for this insertion if we've
/// just been added to the group. Otherwise, we don't want to
/// attribute all the group state to the author of the most recent
/// revision.
let shouldAttributeAuthor: Bool = {
if
didAddLocalUserToV2Group,
newGroupModel.groupMembership.isMemberOfAnyKind(localIdentifiers.aciAddress)
{
return true
}
return false
}()
return insertGroupThreadInDatabaseAndCreateInfoMessage(
groupModel: newGroupModel,
disappearingMessageToken: newDisappearingMessageToken,
groupUpdateSource: shouldAttributeAuthor ? groupUpdateSource : .unknown,
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
transaction: transaction
)
}
}
/// Update persisted group-related state for the provided models. If
/// appropriate, inserts an info message into the group thread describing
/// what has changed about the group.
///
/// - Parameter newlyLearnedPniToAciAssociations
/// Associations between PNIs and ACIs that were learned as a result of this
/// group update.
public static func updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
newGroupModel: TSGroupModel,
newDisappearingMessageToken: DisappearingMessageToken?,
newlyLearnedPniToAciAssociations: [Pni: Aci],
groupUpdateSource: GroupUpdateSource,
infoMessagePolicy: InfoMessagePolicy = .always,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
transaction: SDSAnyWriteTransaction
) throws -> TSGroupThread {
// Step 1: First reload latest thread state. This ensures:
//
// * The thread (still) exists in the database.
// * The update is working off latest database state.
//
// We always have the groupThread at the call sites of this method, but this
// future-proofs us against bugs.
guard let groupThread = TSGroupThread.fetch(groupId: newGroupModel.groupId, transaction: transaction) else {
throw OWSAssertionError("Missing groupThread.")
}
guard
let newGroupModel = newGroupModel as? TSGroupModelV2,
let oldGroupModel = groupThread.groupModel as? TSGroupModelV2
else {
owsFail("[GV1] Should be impossible to update a V1 group!")
}
// Step 2: Update DM configuration in database, if necessary.
let updateDMResult: DisappearingMessagesConfigurationStore.SetTokenResult
if let newDisappearingMessageToken = newDisappearingMessageToken {
// shouldInsertInfoMessage is false because we only want to insert a
// single info message if we update both DM config and thread model.
updateDMResult = updateDisappearingMessageConfiguration(
newToken: newDisappearingMessageToken,
groupThread: groupThread,
tx: transaction
)
} else {
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let dmConfiguration = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read)
updateDMResult = (
oldConfiguration: dmConfiguration,
newConfiguration: dmConfiguration
)
}
// Step 3: If any member was removed, make sure we rotate our sender key
// session.
//
// If *we* were removed, check if the group contained any blocked
// members and make a best-effort attempt to rotate our profile key if
// this was our only mutual group with them.
do {
let oldMembers = oldGroupModel.membership.allMembersOfAnyKindServiceIds
let newMembers = newGroupModel.membership.allMembersOfAnyKindServiceIds
if oldMembers.subtracting(newMembers).isEmpty == false {
SSKEnvironment.shared.senderKeyStoreRef.resetSenderKeySession(for: groupThread, transaction: transaction)
}
if
DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isPrimaryDevice ?? true,
let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read)?.aci,
oldGroupModel.membership.hasProfileKeyInGroup(serviceId: localAci),
!newGroupModel.membership.hasProfileKeyInGroup(serviceId: localAci)
{
// If our profile key is no longer exposed to the group - for
// example, we've left the group - check if the group had any
// blocked users to whom our profile key was exposed.
var shouldRotateProfileKey = false
for member in oldMembers {
let memberAddress = SignalServiceAddress(member)
if
(
SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(memberAddress, transaction: transaction)
|| DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(memberAddress, tx: transaction.asV2Read)
),
newGroupModel.membership.canViewProfileKeys(serviceId: member)
{
// Make a best-effort attempt to find other groups with
// this blocked user in which our profile key is
// exposed.
//
// We can only efficiently query for groups in which
// they are a full member, although that may not be all
// the groups in which they can see your profile key.
// Best effort.
let mutualGroupThreads = Self.mutualGroupThreads(
with: member,
localAci: localAci,
tx: transaction
)
// If there is exactly one group, it's the one we are leaving!
// We should rotate, as it's the last group we have in common.
if mutualGroupThreads.count == 1 {
shouldRotateProfileKey = true
break
}
}
}
if shouldRotateProfileKey {
SSKEnvironment.shared.profileManagerRef.forceRotateLocalProfileKeyForGroupDeparture(with: transaction)
}
}
}
// Step 4: Update group in database, if necessary.
let hasUserFacingUpdate: Bool = {
guard newGroupModel.revision > oldGroupModel.revision else {
/// Local group state must never revert to an earlier revision.
///
/// Races exist in the GV2 code, so if we find ourselves with a
/// redundant update we'll simply drop it.
///
/// Note that (excepting bugs elsewhere in the GV2 code) no
/// matter which codepath learned about a particular revision,
/// the group models each codepath constructs for that revision
/// should be equivalent.
Logger.warn("Skipping redundant update for V2 group.")
return false
}
autoWhitelistGroupIfNecessary(
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
groupUpdateSource: groupUpdateSource,
localIdentifiers: localIdentifiers,
tx: transaction
)
let hasUserFacingGroupModelChange = newGroupModel.hasUserFacingChangeCompared(
to: oldGroupModel
)
let hasDMUpdate = updateDMResult.newConfiguration != updateDMResult.oldConfiguration
let hasUserFacingUpdate = hasUserFacingGroupModelChange || hasDMUpdate
groupThread.update(
with: newGroupModel,
shouldUpdateChatListUi: hasUserFacingUpdate,
transaction: transaction
)
return hasUserFacingUpdate
}()
guard hasUserFacingUpdate else {
return groupThread
}
switch infoMessagePolicy {
case .always, .updatesOnly:
insertGroupUpdateInfoMessage(
groupThread: groupThread,
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
oldDisappearingMessageToken: updateDMResult.oldConfiguration.asToken,
newDisappearingMessageToken: updateDMResult.newConfiguration.asToken,
newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations,
groupUpdateSource: groupUpdateSource,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
transaction: transaction
)
default:
break
}
return groupThread
}
private static func mutualGroupThreads(
with member: ServiceId,
localAci: Aci,
tx: SDSAnyReadTransaction
) -> [TSGroupThread] {
return DependenciesBridge.shared.groupMemberStore
.groupThreadIds(
withFullMember: member,
tx: tx.asV2Read
)
.lazy
.compactMap { groupThreadId in
return TSGroupThread.anyFetchGroupThread(uniqueId: groupThreadId, transaction: tx)
}
.filter { groupThread in
return groupThread.groupMembership.hasProfileKeyInGroup(serviceId: localAci)
}
}
public static func hasMutualGroupThread(
with member: ServiceId,
localAci: Aci,
tx: SDSAnyReadTransaction
) -> Bool {
let mutualGroupThreads = Self.mutualGroupThreads(
with: member,
localAci: localAci,
tx: tx
)
return !mutualGroupThreads.isEmpty
}
// MARK: - Storage Service
private static func notifyStorageServiceOfInsertedGroup(groupModel: TSGroupModel,
transaction: SDSAnyReadTransaction) {
guard let groupModel = groupModel as? TSGroupModelV2 else {
// We only need to notify the storage service about v2 groups.
return
}
guard !SSKEnvironment.shared.groupsV2Ref.isGroupKnownToStorageService(groupModel: groupModel,
transaction: transaction) else {
// To avoid redundant storage service writes,
// don't bother notifying the storage service
// about v2 groups it already knows about.
return
}
SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(groupModel: groupModel)
}
// MARK: - Profiles
private static func autoWhitelistGroupIfNecessary(
oldGroupModel: TSGroupModel?,
newGroupModel: TSGroupModel,
groupUpdateSource: GroupUpdateSource,
localIdentifiers: LocalIdentifiers,
tx: SDSAnyWriteTransaction
) {
let justAdded = wasLocalUserJustAddedToTheGroup(
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
localIdentifiers: localIdentifiers
)
guard justAdded else {
return
}
let shouldAddToWhitelist: Bool
switch groupUpdateSource {
case .unknown, .legacyE164, .rejectedInviteToPni:
// Invalid updaters, shouldn't add.
shouldAddToWhitelist = false
case .aci(let aci):
shouldAddToWhitelist = SSKEnvironment.shared.profileManagerRef.isUser(inProfileWhitelist: SignalServiceAddress(aci), transaction: tx)
case .localUser:
// Always whitelist if its the local user updating.
shouldAddToWhitelist = true
}
guard shouldAddToWhitelist else {
return
}
// Ensure the thread is in our profile whitelist 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.
SSKEnvironment.shared.profileManagerRef.addGroupId(
toProfileWhitelist: newGroupModel.groupId, userProfileWriter: .localUser, transaction: tx
)
}
private static func wasLocalUserJustAddedToTheGroup(
oldGroupModel: TSGroupModel?,
newGroupModel: TSGroupModel,
localIdentifiers: LocalIdentifiers
) -> Bool {
if let oldGroupModel, oldGroupModel.groupMembership.isFullMember(localIdentifiers.aci) {
// Local user already was a member.
return false
}
if !newGroupModel.groupMembership.isFullMember(localIdentifiers.aci) {
// Local user is not a member.
return false
}
return true
}
// MARK: -
/// A profile key is considered "authoritative" when it comes in on a group
/// change action and the owner of the profile key matches the group change
/// action author. We consider an "authoritative" profile key the source of
/// truth. Even if we have a different profile key for this user already,
/// we consider this authoritative profile key the correct, most up-to-date
/// one. A "non-authoritative" profile key, on the other hand, may or may
/// not be the most up to date profile key for a user (such as if one user
/// adds another to a group without having their latest profile key), and we
/// only use it if we have no other profile key for the user already.
///
/// - Parameter allProfileKeysByAci: contains both authoritative and
/// non-authoritative profile keys.
///
/// - Parameter authoritativeProfileKeysByAci: contains just authoritative
/// profile keys. If authoritative profile keys can't be determined, pass
/// an empty Dictionary.
public static func storeProfileKeysFromGroupProtos(
allProfileKeysByAci: [Aci: Data],
authoritativeProfileKeysByAci: [Aci: Data] = [:],
localIdentifiers: LocalIdentifiers,
tx: SDSAnyWriteTransaction
) {
// We trust what is locally-stored as the local user's profile key to be
// more authoritative than what is stored in the group state on the server.
var authoritativeProfileKeysByAci = authoritativeProfileKeysByAci
authoritativeProfileKeysByAci.removeValue(forKey: localIdentifiers.aci)
SSKEnvironment.shared.profileManagerRef.fillInProfileKeys(
allProfileKeys: allProfileKeysByAci,
authoritativeProfileKeys: authoritativeProfileKeysByAci,
userProfileWriter: .groupState,
localIdentifiers: localIdentifiers,
tx: tx.asV2Write
)
}
/// Ensure that we have a profile key commitment for our local profile
/// available on the service.
///
/// We (and other clients) need profile key credentials for group members in
/// order to perform GV2 operations. However, other clients can't request
/// our profile key credential from the service until we've uploaded a profile
/// key commitment to the service.
public static func ensureLocalProfileHasCommitmentIfNecessary() async throws {
let accountManager = DependenciesBridge.shared.tsAccountManager
func hasProfileKeyCredential() throws -> Bool {
return try SSKEnvironment.shared.databaseStorageRef.read { tx in
guard accountManager.registrationState(tx: tx.asV2Read).isRegistered else {
return false
}
guard let localAddress = accountManager.localIdentifiers(tx: tx.asV2Read)?.aciAddress else {
throw OWSAssertionError("Missing localAddress.")
}
return SSKEnvironment.shared.groupsV2Ref.hasProfileKeyCredential(for: localAddress, transaction: tx)
}
}
guard try !hasProfileKeyCredential() else {
return
}
// If we don't have a local profile key credential we should first
// check if it is simply expired, by asking for a new one (which we
// would get as part of fetching our local profile).
_ = try await SSKEnvironment.shared.profileManagerRef.fetchLocalUsersProfile(authedAccount: .implicit()).awaitable()
guard try !hasProfileKeyCredential() else {
return
}
guard
CurrentAppContext().isMainApp,
SSKEnvironment.shared.databaseStorageRef.read(block: { tx in
accountManager.registrationState(tx: tx.asV2Read).isRegisteredPrimaryDevice
})
else {
Logger.warn("Skipping upload of local profile key commitment, not in main app!")
return
}
// We've never uploaded a profile key commitment - do so now.
Logger.info("No profile key credential available for local account - uploading local profile!")
_ = await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
SSKEnvironment.shared.profileManagerRef.reuploadLocalProfile(
unsavedRotatedProfileKey: nil,
mustReuploadAvatar: false,
authedAccount: .implicit(),
tx: tx.asV2Write
)
}
}
}
// MARK: -
public extension GroupManager {
class func waitForMessageFetchingAndProcessingWithTimeout(description: String) async throws {
return try await Promise.wrapAsync {
await SSKEnvironment.shared.messageProcessorRef.waitForFetchingAndProcessing().awaitable()
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration, description: description) {
return GroupsV2Error.timeout
}.awaitable()
}
}
// MARK: - Add/Invite to group
extension GroupManager {
public static func addOrInvite(
serviceIds: [ServiceId],
toExistingGroup existingGroupModel: TSGroupModel
) async throws -> TSGroupThread {
guard let existingGroupModel = existingGroupModel as? TSGroupModelV2 else {
owsFail("[GV1] Mutations on V1 groups should be impossible!")
}
// Ensure we have fetched profile key credentials before performing
// the add below, since we depend on credential state to decide
// whether to add or invite a user.
try await SSKEnvironment.shared.groupsV2Ref.tryToFetchProfileKeyCredentials(
for: serviceIds.compactMap { $0 as? Aci },
ignoreMissingProfiles: false,
forceRefresh: false
)
return try await updateGroupV2(
groupModel: existingGroupModel,
description: "Add/Invite new non-admin members"
) { groupChangeSet in
SSKEnvironment.shared.databaseStorageRef.read { transaction in
for serviceId in serviceIds {
owsAssertDebug(!existingGroupModel.groupMembership.isMemberOfAnyKind(serviceId))
// Important that at this point we already have the
// profile keys for these users
let hasCredential = SSKEnvironment.shared.groupsV2Ref.hasProfileKeyCredential(
for: SignalServiceAddress(serviceId),
transaction: transaction
)
if let aci = serviceId as? Aci, hasCredential {
groupChangeSet.addMember(aci, role: .normal)
} else {
groupChangeSet.addInvitedMember(serviceId, role: .normal)
}
if let aci = serviceId as? Aci, existingGroupModel.groupMembership.isBannedMember(aci) {
groupChangeSet.removeBannedMember(aci)
}
}
}
}
}
}
// MARK: - Update attributes
extension GroupManager {
public static func updateGroupAttributes(
title: String?,
description: String?,
avatarData: Data?,
inExistingGroup existingGroupModel: TSGroupModel
) async throws {
guard let existingGroupModel = existingGroupModel as? TSGroupModelV2 else {
owsFail("[GV1] Mutations on V1 groups should be impossible!")
}
let avatarUrlPath = try await { () -> String? in
guard let avatarData else {
return nil
}
// Skip upload if the new avatar data is the same as the existing
if
let existingAvatarHash = existingGroupModel.avatarHash,
try existingAvatarHash == TSGroupModel.hash(forAvatarData: avatarData)
{
return nil
}
return try await SSKEnvironment.shared.groupsV2Ref.uploadGroupAvatar(
avatarData: avatarData,
groupSecretParams: try existingGroupModel.secretParams()
)
}()
var message = "Update attributes:"
message += title != nil ? " title" : ""
message += description != nil ? " description" : ""
message += avatarData != nil ? " settingAvatarData" : " clearingAvatarData"
_ = try await self.updateGroupV2(
groupModel: existingGroupModel,
description: message
) { groupChangeSet in
if
let title = title?.ows_stripped(),
title != existingGroupModel.groupName
{
groupChangeSet.setTitle(title)
}
if
let description = description?.ows_stripped(),
description != existingGroupModel.descriptionText
{
groupChangeSet.setDescriptionText(description)
} else if
description == nil,
existingGroupModel.descriptionText != nil
{
groupChangeSet.setDescriptionText(nil)
}
// Having a URL from the previous step means this data
// represents a new avatar, which we have already uploaded.
if
let avatarData = avatarData,
let avatarUrlPath = avatarUrlPath
{
groupChangeSet.setAvatar((data: avatarData, urlPath: avatarUrlPath))
} else if
avatarData == nil,
existingGroupModel.avatarData != nil
{
groupChangeSet.setAvatar(nil)
}
}
}
}