Path: blob/main/SignalServiceKit/Groups/GroupManager.swift
1 views
//
// 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.
override private init() {}
// MARK: -
// GroupsV2 TODO: Finalize this value with the designers.
public static let groupUpdateTimeoutDuration: TimeInterval = 30
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
// Epoch 6: Member Labels
// Epoch 7: Group Terminate
public static let changeProtoEpoch: UInt32 = 7
public static let maxEmbeddedChangeProtoLength: UInt = UInt(OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes)
// MARK: - Group IDs
static func groupIdLength(for groupsVersion: GroupsVersion) -> UInt {
switch groupsVersion {
case .V1:
return kGroupIdLengthV1
case .V2:
return kGroupIdLengthV2
}
}
public static func isV1GroupId(_ groupId: Data) -> Bool {
groupId.count == groupIdLength(for: .V1)
}
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
}
public static func isValidGroupIdOfAnyKind(_ groupId: Data) -> Bool {
return isV1GroupId(groupId) || isV2GroupId(groupId)
}
// MARK: - Group Models
/// 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.
public static func localCreateNewGroup(
seed: NewGroupSeed,
members membersParam: [SignalServiceAddress],
name: StrippedNonEmptyString,
avatarData: Data?,
disappearingMessageToken: DisappearingMessageToken,
) async throws -> TSGroupThread {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
throw OWSAssertionError("Missing localIdentifiers.")
}
var otherMembers = membersParam.compactMap(\.serviceId)
otherMembers.removeAll(where: { $0 == localIdentifiers.aci })
try await ensureLocalProfileHasCommitmentIfNecessary()
var downloadedAvatars = GroupAvatarStateMap()
let newGroupParams = GroupsV2Protos.NewGroupParams(
secretParams: seed.groupSecretParams,
title: name,
avatarUrlPath: try await { () async throws -> String? in
guard let avatarData else {
return nil
}
// Upload avatar.
let avatarUrlPath = try await SSKEnvironment.shared.groupsV2Ref.uploadGroupAvatar(
avatarData: avatarData,
groupSecretParams: seed.groupSecretParams,
)
downloadedAvatars.set(avatarDataState: .available(avatarData), avatarUrlPath: avatarUrlPath)
return avatarUrlPath
}(),
otherMembers: otherMembers,
disappearingMessageToken: disappearingMessageToken,
)
let snapshotResponse = try await SSKEnvironment.shared.groupsV2Ref.createNewGroupOnService(
newGroupParams,
downloadedAvatars: downloadedAvatars,
localAci: localIdentifiers.aci,
)
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)),
infoMessagePolicy: .insert,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: tx,
)
SSKEnvironment.shared.profileManagerRef.addGroupId(
toProfileWhitelist: groupModel.groupId,
userProfileWriter: .localUser,
transaction: tx,
)
if let groupSendEndorsementsResponse = snapshotResponse.groupSendEndorsementsResponse {
SSKEnvironment.shared.groupsV2Ref.handleGroupSendEndorsementsResponse(
groupSendEndorsementsResponse,
groupThreadId: thread.sqliteRowId!,
secretParams: snapshotResponse.groupSnapshot.groupSecretParams,
membership: snapshotResponse.groupSnapshot.groupMembership,
localAci: localIdentifiers.aci,
tx: tx,
)
}
return thread
}
await sendDurableNewGroupMessage(forThread: thread)
return thread
}
// 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,
transaction: DBWriteTransaction,
) throws -> TSGroupThread {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction) 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.
let secretParams = try GroupSecretParams.generate()
var builder = TSGroupModelBuilder(secretParams: secretParams)
builder.name = name
builder.descriptionText = descriptionText
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 remoteUpsertExistingGroupForTests(
groupModel: groupModel,
disappearingMessageToken: nil,
groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
infoMessagePolicy: shouldInsertInfoMessage ? .insert : .doNotInsert,
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 = .insert,
localIdentifiers: LocalIdentifiers,
transaction: DBWriteTransaction,
) -> TSGroupThread {
return 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: DBWriteTransaction,
) -> DisappearingMessagesConfigurationStore.SetTokenResult {
let setTokenResult = DependenciesBridge.shared.disappearingMessagesConfigurationStore
.set(token: newToken, for: groupThread, tx: tx)
if setTokenResult.newConfiguration.asToken != setTokenResult.oldConfiguration.asToken {
SSKEnvironment.shared.databaseStorageRef.touch(thread: groupThread, shouldReindex: false, tx: 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: DBWriteTransaction,
) {
_ = self.updateDisappearingMessagesInDatabaseAndCreateMessages(
newToken: disappearingMessageToken,
contactThread: contactThread,
changeAuthor: changeAuthor,
localIdentifiers: localIdentifiers,
transaction: transaction,
)
}
public static func localUpdateDisappearingMessageToken(
_ disappearingMessageToken: VersionedDisappearingMessageToken,
inContactThread contactThread: TSContactThread,
tx: DBWriteTransaction,
) {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx) 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: DBWriteTransaction,
) -> DisappearingMessagesConfigurationStore.SetTokenResult {
let result = DependenciesBridge.shared.disappearingMessagesConfigurationStore
.set(
token: newToken,
for: .thread(contactThread),
tx: transaction,
)
// Skip redundant updates.
if result.newConfiguration.asToken != result.oldConfiguration.asToken {
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, tx: transaction)
}
return result
}
private static func sendDisappearingMessagesConfigurationMessage(
updateResult: DisappearingMessagesConfigurationStore.SetTokenResult,
contactThread: TSContactThread,
transaction: DBWriteTransaction,
) {
guard updateResult.newConfiguration.asVersionedToken != updateResult.oldConfiguration.asVersionedToken else {
// The update was redundant, don't send an update message.
return
}
let newConfiguration = updateResult.newConfiguration
let message = DisappearingMessagesConfigurationMessage(
configuration: newConfiguration,
contactThread: contactThread,
tx: 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()
}
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,
isDeletingAccount: Bool = false,
tx: DBWriteTransaction,
) -> Promise<[Promise<Void>]> {
return SSKEnvironment.shared.localUserLeaveGroupJobQueueRef.addJob(
groupThread: groupThread,
replacementAdminAci: replacementAdminAci,
waitForMessageProcessing: waitForMessageProcessing,
isDeletingAccount: isDeletingAccount,
tx: tx,
)
}
public static func leaveGroupOrDeclineInviteAsyncWithoutUI(groupThread: TSGroupThread, tx: DBWriteTransaction) {
guard groupThread.groupModel.groupMembership.isLocalUserMemberOfAnyKind else {
owsFailDebug("unexpectedly trying to leave group for which we're not a member.")
return
}
tx.addSyncCompletion {
Task {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
let leavePromise = await databaseStorage.awaitableWrite { tx in
return self.localLeaveGroupOrDeclineInvite(groupThread: groupThread, tx: tx)
}
do {
_ = try await leavePromise.awaitable()
} catch {
owsFailDebug("Couldn't leave group: \(error)")
}
}
}
}
// MARK: - Remove From Group / Revoke Invite
public static func removeFromGroupOrRevokeInviteV2(
groupModel: TSGroupModelV2,
serviceIds: [ServiceId],
) async throws {
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)
}
}
}
public static func revokeInvalidInvites(groupModel: TSGroupModelV2) async throws {
try await updateGroupV2(groupModel: groupModel, description: "Revoke invalid invites") { groupChangeSet in
groupChangeSet.revokeInvalidInvites()
}
}
// MARK: - Change Member Role
private static func acisToClearMemberLabelsFor(groupModel: TSGroupModelV2, access: GroupV2Access) -> [Aci] {
let acisToClearMemberLabelsFor: [Aci] = []
switch access {
case .administrator:
let adminSet = groupModel.groupMembership.fullMemberAdministrators
let memberSet = groupModel.groupMembership.fullMembers
let nonAdminMembers = memberSet.subtracting(adminSet)
return nonAdminMembers.compactMap { $0.aci }
case .unknown, .any, .member, .unsatisfiable:
break
}
return acisToClearMemberLabelsFor
}
public static func changeMemberRoleV2(
groupModel: TSGroupModelV2,
aci: Aci,
role: TSGroupMemberRole,
) async throws {
try await updateGroupV2(groupModel: groupModel, description: "Change member role") { groupChangeSet in
groupChangeSet.changeRoleForMember(aci, role: role)
if BuildFlags.MemberLabel.send {
if role == .normal, groupModel.access.memberLabels == .administrator {
groupChangeSet.changeLabelForMember(aci, label: nil)
}
}
}
}
// 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)
}
}
public static func changeGroupMemberLabelsAccessV2(groupModel: TSGroupModelV2, access: GroupV2Access) async throws {
try await updateGroupV2(groupModel: groupModel, description: "Change group member labels access") { groupChangeSet in
groupChangeSet.setAccessForMemberLabels(access)
if BuildFlags.MemberLabel.send {
let acisToClear = acisToClearMemberLabelsFor(groupModel: groupModel, access: access)
for aci in acisToClear {
groupChangeSet.changeLabelForMember(aci, label: nil)
}
}
}
}
// MARK: - Group Links
public static func updateLinkModeV2(groupModel: TSGroupModelV2, linkMode: GroupsV2LinkMode) async throws {
try await updateGroupV2(groupModel: groupModel, description: "Change group link mode") { groupChangeSet in
groupChangeSet.setLinkMode(linkMode)
}
}
public static func resetLinkV2(groupModel: TSGroupModelV2) async throws {
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(
secretParams: GroupSecretParams,
inviteLinkPassword: Data,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
) async throws {
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
try await ensureLocalProfileHasCommitmentIfNecessary()
try await SSKEnvironment.shared.groupsV2Ref.joinGroupViaInviteLink(
secretParams: secretParams,
inviteLinkPassword: inviteLinkPassword,
downloadedAvatar: downloadedAvatar,
)
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
SSKEnvironment.shared.profileManagerRef.addGroupId(
toProfileWhitelist: groupId.serialize(),
userProfileWriter: .localUser,
transaction: transaction,
)
}
}
public static func acceptOrDenyMemberRequestsV2(
groupModel: TSGroupModelV2,
aci: Aci,
shouldAccept: Bool,
) async throws {
let description = (shouldAccept ? "Accept group member request" : "Deny group member request")
try await updateGroupV2(groupModel: groupModel, description: description) { groupChangeSet in
if shouldAccept {
groupChangeSet.addMember(aci)
} else {
groupChangeSet.removeMember(aci)
}
}
}
public static func cancelRequestToJoin(groupModel: TSGroupModelV2) async throws {
let description = "Cancel Request to Join"
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
/// - Returns: A list of Promises for sending the group update message(s).
/// Each Promise represents sending a message to one or more recipients.
@discardableResult
public static func updateLocalProfileKey(groupModel: TSGroupModelV2) async throws -> [Promise<Void>] {
return try await updateGroupV2(groupModel: groupModel, description: "Update local profile key") { changes in
changes.setShouldUpdateLocalProfileKey()
}
}
// MARK: - Group Terminate
public static func terminateGroup(groupModel: TSGroupModelV2, threadId: Int64) async throws {
try await refreshGroupSendEndorsementsIfNeeded(threadId: threadId, groupModel: groupModel)
try await updateGroupV2(groupModel: groupModel, description: "Terminate group") { groupChangeSet in
groupChangeSet.setShouldTerminateGroup()
}
}
static func refreshGroupSendEndorsementsIfNeeded(
threadId: TSGroupThread.RowId,
groupModel: TSGroupModelV2,
) async throws {
// If we're not a full member, we can't fetch credentials.
guard groupModel.groupMembership.isLocalUserFullMember else {
return
}
guard !groupModel.isTerminated else {
return
}
let groupSendEndorsementStore = DependenciesBridge.shared.groupSendEndorsementStore
let combinedEndorsement = SSKEnvironment.shared.databaseStorageRef.read { tx in
return try? groupSendEndorsementStore.fetchCombinedEndorsement(groupThreadId: threadId, tx: tx)
}
// If we have recent-ish credentials, we don't need to refresh.
guard GroupSendEndorsements.willExpireSoon(expirationDate: combinedEndorsement?.expiration) else {
return
}
let secretParams = try groupModel.secretParams()
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
Logger.info("Refreshing GSEs before leaving \(groupId)")
// Otherwise, try to refresh the credentials to use them when leaving.
try await SSKEnvironment.shared.groupV2UpdatesRef.refreshGroup(secretParams: secretParams)
}
// MARK: - Removed from Group or Invite Revoked
public static func handleNotInGroup(groupId: GroupIdentifier) async {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
do {
let groupThread = databaseStorage.read { tx in TSGroupThread.fetch(forGroupId: groupId, tx: tx) }
guard let groupThread else {
// We may be 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
// If this is a join request placeholder, we don't expect to have access to
// the group, but we should have access to the invite link preview without
// needing to provide an inviteLinkPassword.
if
let groupModelV2 = groupModel as? TSGroupModelV2,
groupModelV2.isJoinRequestPlaceholder,
groupModelV2.groupMembership.isLocalUserRequestingMember
{
do {
let secretParams = try groupModelV2.secretParams()
_ = try await SSKEnvironment.shared.groupsV2Ref.fetchGroupInviteLinkPreview(
inviteLinkPassword: nil,
groupSecretParams: secretParams,
)
// We still have access to the group, so do nothing.
return
} catch GroupsV2Error.localUserIsNotARequestingMember {
// Expected if our request has been cancelled. In this scenario, we should
// remove ourselves from the local group state.
} catch {
// We don't know what went wrong; do nothing.
owsFailDebug("Error: \(error)")
return
}
}
}
await databaseStorage.awaitableWrite { tx in
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
owsFailDebug("Missing localIdentifiers.")
return
}
guard let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx) else {
owsFailDebug("Couldn't fetch thread that's guaranteed to exist.")
return
}
let groupModel = groupThread.groupModel
// 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.
updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
groupThread: groupThread,
newGroupModel: newGroupModel,
newDisappearingMessageToken: nil,
newlyLearnedPniToAciAssociations: [:],
groupUpdateSource: .unknown,
infoMessagePolicy: .insert,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: tx,
)
} catch {
owsFailDebug("Error: \(error)")
}
}
}
public static func changeMemberLabel(
groupModel: TSGroupModelV2,
aci: Aci,
label: MemberLabel?,
) async throws {
try await updateGroupV2(groupModel: groupModel, description: "Change member label") { groupChangeSet in
groupChangeSet.changeLabelForMember(aci, label: label)
}
}
// MARK: - Messages
public static func sendGroupUpdateMessage(
groupId: GroupIdentifier,
isUrgent: Bool = false,
isDeletingAccount: Bool = false,
groupChangeProtoData: Data? = nil,
) async -> Promise<Void> {
return await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction -> Promise<Void> in
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
guard let thread = TSGroupThread.fetch(forGroupId: groupId, tx: transaction) else {
return Promise(error: OWSAssertionError("couldn't send group update message to missing thread"))
}
let message = OutgoingGroupUpdateMessage(
in: thread,
expiresInSeconds: dmConfigurationStore.durationSeconds(for: thread, tx: transaction),
groupChangeProtoData: groupChangeProtoData,
additionalRecipients: Self.invitedMembers(in: thread),
isUrgent: isUrgent,
isDeletingAccount: isDeletingAccount,
transaction: transaction,
)
// "changeActionsProtoData" is _not_ an attachment, it is just put on
// the outgoing proto directly.
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: message,
)
return SSKEnvironment.shared.messageSenderJobQueueRef.add(.promise, 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,
expiresInSeconds: dmConfigurationStore.durationSeconds(for: thread, tx: tx),
additionalRecipients: Self.invitedMembers(in: thread),
isUrgent: true,
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) -> some Sequence<ServiceId> {
thread.groupModel.groupMembership.invitedMembers.compactMap(\.serviceId)
}
static func shouldMessageHaveAdditionalRecipients(
_ message: any SendableMessage,
groupThread: TSGroupThread,
) -> Bool {
guard groupThread.groupModel.groupsVersion == .V2 else {
return false
}
return message is OutgoingGroupUpdateMessage
}
// MARK: - Group Database
public enum InfoMessagePolicy {
case insert
case doNotInsert
}
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
private static func insertGroupThreadInDatabaseAndCreateInfoMessage(
groupModel: TSGroupModelV2,
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSource: GroupUpdateSource,
infoMessagePolicy: InfoMessagePolicy,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
transaction: DBWriteTransaction,
) -> TSGroupThread {
if let groupThread = TSGroupThread.fetch(groupId: groupModel.groupId, transaction: transaction) {
owsFail("Inserting existing group thread: \(groupThread.logString).")
}
let groupThread = DependenciesBridge.shared.threadStore.createGroupThread(
groupModel: groupModel,
tx: transaction,
)
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 .insert:
insertGroupUpdateInfoMessageForNewGroup(
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
groupThread: groupThread,
groupModel: groupModel,
disappearingMessageToken: newDisappearingMessageToken,
groupUpdateSource: groupUpdateSource,
transaction: transaction,
)
case .doNotInsert:
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,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
transaction: DBWriteTransaction,
) -> TSGroupThread {
if DebugFlags.internalLogging {
let groupId = try? newGroupModel.secretParams().getPublicParams().getGroupIdentifier()
Logger.info("Upserting thread for \(groupId as Optional); didAddLocalUser? \(didAddLocalUserToV2Group); groupUpdateSource: \(groupUpdateSource)")
}
if let groupThread = TSGroupThread.fetch(groupId: newGroupModel.groupId, transaction: transaction) {
updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
groupThread: groupThread,
newGroupModel: newGroupModel,
newDisappearingMessageToken: newDisappearingMessageToken,
newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations,
groupUpdateSource: groupUpdateSource,
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
transaction: transaction,
)
return groupThread
} 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
}()
if DebugFlags.internalLogging {
let groupId = try? newGroupModel.secretParams().getPublicParams().getGroupIdentifier()
Logger.info("Inserting thread for \(groupId as Optional); shouldAttributeAuthor? \(shouldAttributeAuthor)")
}
insertRecipients(
addedMembers: newGroupModel.groupMembership.allMembersOfAnyKindServiceIds,
localIdentifiers: localIdentifiers,
tx: transaction,
)
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(
groupThread: TSGroupThread,
newGroupModel: TSGroupModel,
newDisappearingMessageToken: DisappearingMessageToken?,
newlyLearnedPniToAciAssociations: [Pni: Aci],
groupUpdateSource: GroupUpdateSource,
infoMessagePolicy: InfoMessagePolicy = .insert,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
transaction: DBWriteTransaction,
) {
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 {
// 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)
updateDMResult = (
oldConfiguration: dmConfiguration,
newConfiguration: dmConfiguration,
)
}
do {
let oldMembers = oldGroupModel.membership.allMembersOfAnyKindServiceIds
let newMembers = newGroupModel.membership.allMembersOfAnyKindServiceIds
insertRecipients(
addedMembers: newMembers.subtracting(oldMembers),
localIdentifiers: localIdentifiers,
tx: transaction,
)
}
// 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 somebody else was removed, reset the sender key session.
let removedMembers = oldMembers.subtracting(newMembers)
if !removedMembers.subtracting([localIdentifiers.aci]).isEmpty {
SSKEnvironment.shared.senderKeyStoreRef.resetSenderKeySession(for: groupThread, transaction: transaction)
}
if
DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction).isPrimaryDevice ?? true,
oldGroupModel.membership.hasProfileKeyInGroup(serviceId: localIdentifiers.aci),
!newGroupModel.membership.hasProfileKeyInGroup(serviceId: localIdentifiers.aci)
{
// 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)
,
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: localIdentifiers.aci,
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.
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
}
autoWhitelistGroupIfNecessary(
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
groupUpdateSource: groupUpdateSource,
localIdentifiers: localIdentifiers,
tx: transaction,
)
let showInfoMessageForChange: Bool = (
newGroupModel.showInfoMessageForChangeComparedTo(to: oldGroupModel)
|| updateDMResult.newConfiguration.asVersionedToken != updateDMResult.oldConfiguration.asVersionedToken,
)
groupThread.update(
with: newGroupModel,
transaction: transaction,
)
let shouldInsertInfoMessages: Bool
switch infoMessagePolicy {
case .insert:
shouldInsertInfoMessages = true
case .doNotInsert:
shouldInsertInfoMessages = false
}
if showInfoMessageForChange, shouldInsertInfoMessages {
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,
)
}
}
private static func mutualGroupThreads(
with member: ServiceId,
localAci: Aci,
tx: DBReadTransaction,
) -> [TSGroupThread] {
return DependenciesBridge.shared.groupMemberStore
.groupThreadIds(
withFullMember: member,
tx: tx,
)
.lazy
.compactMap { groupThreadId in
return TSGroupThread.fetchGroupThreadViaCache(uniqueId: groupThreadId, transaction: tx)
}
.filter { groupThread in
return groupThread.groupMembership.hasProfileKeyInGroup(serviceId: localAci)
}
}
public static func hasMutualGroupThread(
with member: ServiceId,
localAci: Aci,
tx: DBReadTransaction,
) -> Bool {
let mutualGroupThreads = Self.mutualGroupThreads(
with: member,
localAci: localAci,
tx: tx,
)
return !mutualGroupThreads.isEmpty
}
private static func insertRecipients(addedMembers: Set<ServiceId>, localIdentifiers: LocalIdentifiers, tx: DBWriteTransaction) {
let recipientFetcher = DependenciesBridge.shared.recipientFetcher
let recipientManager = DependenciesBridge.shared.recipientManager
for addedMember in addedMembers {
if localIdentifiers.contains(serviceId: addedMember) {
continue
}
var (inserted, recipient) = recipientFetcher.fetchOrCreateImpl(serviceId: addedMember, tx: tx)
if inserted {
recipientManager.markAsRegisteredAndSave(&recipient, shouldUpdateStorageService: true, tx: tx)
}
}
}
// MARK: - Storage Service
private static func notifyStorageServiceOfInsertedGroup(
groupModel: TSGroupModel,
transaction: DBReadTransaction,
) {
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: DBWriteTransaction,
) {
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
}
if DebugFlags.internalLogging {
let groupId = try? (newGroupModel as? TSGroupModelV2)?.secretParams().getPublicParams().getGroupIdentifier()
Logger.info("Checking if group should be auto whitelisted \(groupId as Optional); groupUpdateSource: \(groupUpdateSource); shouldAddToWhitelist? \(shouldAddToWhitelist)")
}
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 {
let oldFullMember = oldGroupModel?.groupMembership.isFullMember(localIdentifiers.aci) == true
let newFullMember = newGroupModel.groupMembership.isFullMember(localIdentifiers.aci)
if DebugFlags.internalLogging {
let groupId = try? (newGroupModel as? TSGroupModelV2)?.secretParams().getPublicParams().getGroupIdentifier()
Logger.info("Checking if local user was added to \(groupId as Optional); oldGroupModel? \(oldGroupModel != nil); oldFullMember? \(oldFullMember); newFullMember: \(newFullMember)")
}
return !oldFullMember && newFullMember
}
// 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: DBWriteTransaction,
) {
// 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,
)
}
private static let localProfileCommitmentQueue = ConcurrentTaskQueue(concurrentLimit: 1)
/// 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 {
try await localProfileCommitmentQueue.run {
try await _ensureLocalProfileHasCommitmentIfNecessary()
}
}
private static func _ensureLocalProfileHasCommitmentIfNecessary() async throws {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
func hasProfileKeyCredential() -> Bool {
return SSKEnvironment.shared.databaseStorageRef.read { tx in
let localAci = registeredState.localIdentifiers.aci
return SSKEnvironment.shared.groupsV2Ref.hasProfileKeyCredential(for: localAci, transaction: tx)
}
}
guard !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())
guard !hasProfileKeyCredential() else {
return
}
guard registeredState.isPrimary, CurrentAppContext().isMainApp 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!")
let uploadAndFetchPromise = await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
SSKEnvironment.shared.profileManagerRef.reuploadLocalProfile(
unsavedRotatedProfileKey: nil,
mustReuploadAvatar: false,
authedAccount: .implicit(),
tx: tx,
)
}
try await uploadAndFetchPromise.awaitable()
}
}
// MARK: -
public extension GroupManager {
class func waitForMessageFetchingAndProcessingWithTimeout() async throws {
do {
return try await withCooperativeTimeout(seconds: GroupManager.groupUpdateTimeoutDuration) {
try await SSKEnvironment.shared.messageProcessorRef.waitForFetchingAndProcessing()
}
} catch is CooperativeTimeoutError {
throw GroupsV2Error.timeout
}
}
}
// MARK: - Add/Invite to group
extension GroupManager {
public static func addOrInvite(
serviceIds: [ServiceId],
toExistingGroup existingGroupModel: TSGroupModel,
) async throws {
guard let existingGroupModel = existingGroupModel as? TSGroupModelV2 else {
owsFail("[GV1] Mutations on V1 groups should be impossible!")
}
try await updateGroupV2(
groupModel: existingGroupModel,
description: "Add/Invite new non-admin members",
) { groupChangeSet in
for serviceId in serviceIds {
groupChangeSet.addMember(serviceId)
}
}
}
}
// 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,
let avatarUrlPath
{
groupChangeSet.setAvatar((data: avatarData, urlPath: avatarUrlPath))
} else if
avatarData == nil,
existingGroupModel.avatarUrlPath != nil
{
groupChangeSet.setAvatar(nil)
}
}
}
}