Path: blob/main/SignalServiceKit/Groups/GroupsV2Impl.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
public class GroupsV2Impl: GroupsV2 {
private var urlSession: OWSURLSessionProtocol {
return SSKEnvironment.shared.signalServiceRef.urlSessionForStorageService()
}
private let authCredentialStore: AuthCredentialStore
private let authCredentialManager: any AuthCredentialManager
private let groupSendEndorsementStore: any GroupSendEndorsementStore
init(
appReadiness: AppReadiness,
authCredentialStore: AuthCredentialStore,
authCredentialManager: any AuthCredentialManager,
groupSendEndorsementStore: any GroupSendEndorsementStore,
) {
self.authCredentialStore = authCredentialStore
self.authCredentialManager = authCredentialManager
self.groupSendEndorsementStore = groupSendEndorsementStore
self.profileKeyUpdater = GroupsV2ProfileKeyUpdater(appReadiness: appReadiness)
SwiftSingletons.register(self)
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
return
}
Task {
do {
try await GroupManager.ensureLocalProfileHasCommitmentIfNecessary()
} catch {
Logger.warn("Local profile update failed with error: \(error)")
}
}
}
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
Self.enqueueRestoreGroupPass(authedAccount: .implicit())
}
observeNotifications()
}
private func refreshGroupWithTimeout(secretParams: GroupSecretParams) async throws {
do {
// Ignore the result after the timeout. However, keep refreshing the group
// in the background since the result is still useful/reusable.
try await withUncooperativeTimeout(seconds: GroupManager.groupUpdateTimeoutDuration) {
try await SSKEnvironment.shared.groupV2UpdatesRef.refreshGroup(secretParams: secretParams)
}
} catch is UncooperativeTimeoutError {
throw GroupsV2Error.timeout
}
}
// MARK: - Notifications
private func observeNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(reachabilityChanged),
name: SSKReachability.owsReachabilityDidChange,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationStateDidChange),
name: .registrationStateDidChange,
object: nil,
)
}
@objc
private func didBecomeActive() {
AssertIsOnMainThread()
Self.enqueueRestoreGroupPass(authedAccount: .implicit())
}
@objc
private func reachabilityChanged() {
AssertIsOnMainThread()
Self.enqueueRestoreGroupPass(authedAccount: .implicit())
}
@objc
private func registrationStateDidChange() {
AssertIsOnMainThread()
Self.enqueueRestoreGroupPass(authedAccount: .implicit())
}
// MARK: - Create Group
public func createNewGroupOnService(
_ newGroup: GroupsV2Protos.NewGroupParams,
downloadedAvatars: GroupAvatarStateMap,
localAci: Aci,
) async throws -> GroupV2SnapshotResponse {
do {
return try await _createNewGroupOnService(
newGroup,
downloadedAvatars: downloadedAvatars,
localAci: localAci,
isRetryingAfterRecoverable400: false,
)
} catch GroupsV2Error.serviceRequestHitRecoverable400 {
// We likely failed to create the group because one of the profile key
// credentials we submitted was expired, possibly due to drift between our
// local clock and the service. We should try again exactly once, forcing a
// refresh of all the credentials first.
return try await _createNewGroupOnService(
newGroup,
downloadedAvatars: downloadedAvatars,
localAci: localAci,
isRetryingAfterRecoverable400: true,
)
}
}
private func _createNewGroupOnService(
_ newGroup: GroupsV2Protos.NewGroupParams,
downloadedAvatars: GroupAvatarStateMap,
localAci: Aci,
isRetryingAfterRecoverable400: Bool,
) async throws -> GroupV2SnapshotResponse {
let groupProto = try await self.buildProtoToCreateNewGroupOnService(
newGroup,
localAci: localAci,
shouldForceRefreshProfileKeyCredentials: isRetryingAfterRecoverable400,
)
let requestBuilder: RequestBuilder = { authCredential -> GroupsV2Request in
return try StorageService.buildNewGroupRequest(
groupProto: groupProto,
groupV2Params: GroupV2Params(groupSecretParams: newGroup.secretParams),
authCredential: authCredential,
)
}
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: nil,
behavior400: isRetryingAfterRecoverable400 ? .fail : .reportForRecovery,
behavior403: .fail,
behavior423: .ignore,
)
let groupResponseProto = try GroupsProtoGroupResponse(serializedData: response.responseBodyData ?? Data())
return try GroupsV2Protos.parse(
groupResponseProto: groupResponseProto,
downloadedAvatars: downloadedAvatars,
groupV2Params: GroupV2Params(groupSecretParams: newGroup.secretParams),
)
}
/// Construct the proto to create a new group on the service.
/// - Parameters:
/// - shouldForceRefreshProfileKeyCredentials: Whether we should force-refresh PKCs for the group members.
private func buildProtoToCreateNewGroupOnService(
_ newGroup: GroupsV2Protos.NewGroupParams,
localAci: Aci,
shouldForceRefreshProfileKeyCredentials: Bool,
) async throws -> GroupsProtoGroup {
// Get profile key credentials for everybody who might need them.
let profileKeyCredentialMap = try await loadProfileKeyCredentials(
for: [localAci] + newGroup.otherMembers.compactMap({ $0 as? Aci }),
forceRefresh: shouldForceRefreshProfileKeyCredentials,
)
return try GroupsV2Protos.buildNewGroupProto(
newGroup,
profileKeyCredentials: profileKeyCredentialMap,
localAci: localAci,
)
}
// MARK: - Update Group
// This method updates the group on the service. This corresponds to:
//
// * The local user editing group state (e.g. adding a member).
// * The local user accepting an invite.
// * The local user reject an invite.
// * The local user leaving the group.
// * etc.
//
// Whenever we do this, there's a few follow-on actions that we always want to do (on success):
//
// * Update the group in the local database to reflect the update.
// * Insert "group update info" messages in the conversation history.
// * Send "group update" messages to other members & linked devices.
//
// We do those things here as well, to DRY them up and to ensure they're always
// done immediately and in a consistent way.
private func updateExistingGroupOnService(changes: GroupsV2OutgoingChanges, isDeletingAccount: Bool) async throws -> [Promise<Void>] {
let justUploadedAvatars = GroupAvatarStateMap.from(changes: changes)
let groupV2Params = try GroupV2Params(groupSecretParams: changes.groupSecretParams)
let isAddingOrInviting = changes.membersToAdd.count > 0
let groupUpdateResult: GroupUpdateResult?
do {
groupUpdateResult = try await buildGroupChangeProtoAndTryToUpdateGroupOnService(
groupV2Params: groupV2Params,
changes: changes,
)
} catch {
switch error {
case GroupsV2Error.conflictingChangeOnService:
// If we failed because a conflicting change has already been
// committed to the service, we should refresh our local state
// for the group and try again to apply our changes.
try await refreshGroupWithTimeout(secretParams: groupV2Params.groupSecretParams)
groupUpdateResult = try await buildGroupChangeProtoAndTryToUpdateGroupOnService(
groupV2Params: groupV2Params,
changes: changes,
)
case GroupsV2Error.serviceRequestHitRecoverable400:
// We likely got the 400 because we submitted a proto with
// profile key credentials and one of them was expired, possibly
// due to drift between our local clock and the service. We
// should try again exactly once, forcing a refresh of all the
// credentials first.
groupUpdateResult = try await buildGroupChangeProtoAndTryToUpdateGroupOnService(
groupV2Params: groupV2Params,
changes: changes,
shouldForceRefreshProfileKeyCredentials: true,
forceFailOn400: true,
)
default:
throw error
}
}
guard let groupUpdateResult else {
return []
}
let changeResponse = try GroupsProtoGroupChangeResponse(serializedData: groupUpdateResult.httpResponse.responseBodyData ?? Data())
return try await handleGroupUpdatedOnService(
changeResponse: changeResponse,
messageBehavior: groupUpdateResult.messageBehavior,
justUploadedAvatars: justUploadedAvatars,
isUrgent: isAddingOrInviting,
isDeletingAccount: isDeletingAccount,
groupV2Params: groupV2Params,
)
}
private struct GroupUpdateResult {
var messageBehavior: GroupUpdateMessageBehavior
var httpResponse: HTTPResponse
}
/// Construct a group change proto from the given `changes` for the given
/// `groupId`, and attempt to commit the group change to the service.
/// - Parameters:
/// - shouldForceRefreshProfileKeyCredentials: Whether we should force-refresh PKCs for any new members while building the proto.
/// - forceFailOn400: Whether we should force failure when receiving a 400. If `false`, may instead report expired PKCs.
private func buildGroupChangeProtoAndTryToUpdateGroupOnService(
groupV2Params: GroupV2Params,
changes: GroupsV2OutgoingChanges,
shouldForceRefreshProfileKeyCredentials: Bool = false,
forceFailOn400: Bool = false,
) async throws -> GroupUpdateResult? {
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let (groupThread, dmToken) = try SSKEnvironment.shared.databaseStorageRef.read { tx in
guard let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx) else {
throw OWSAssertionError("Thread does not exist.")
}
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let dmConfiguration = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: tx)
return (groupThread, dmConfiguration.asToken)
}
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid group model.")
}
let builtGroupChange = try await changes.buildGroupChangeProto(
currentGroupModel: groupModel,
currentDisappearingMessageToken: dmToken,
forceRefreshProfileKeyCredentials: shouldForceRefreshProfileKeyCredentials,
)
guard let builtGroupChange else {
return nil
}
var behavior400: Behavior400 = .fail
if
!forceFailOn400,
builtGroupChange.proto.containsProfileKeyCredentials
{
// If the proto we're submitting contains a profile key credential
// that's expired, we'll get back a generic 400. Consequently, if
// we're submitting a proto with PKCs, and we get a 400, we should
// attempt to recover.
behavior400 = .reportForRecovery
}
let requestBuilder: RequestBuilder = { authCredential in
return try StorageService.buildUpdateGroupRequest(
groupChangeProto: builtGroupChange.proto,
groupV2Params: groupV2Params,
authCredential: authCredential,
groupInviteLinkPassword: nil,
)
}
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: groupId,
behavior400: behavior400,
behavior403: .fetchGroupUpdates,
behavior423: .ignore,
)
return GroupUpdateResult(
messageBehavior: builtGroupChange.groupUpdateMessageBehavior,
httpResponse: response,
)
}
private func handleGroupUpdatedOnService(
changeResponse: GroupsProtoGroupChangeResponse,
messageBehavior: GroupUpdateMessageBehavior,
justUploadedAvatars: GroupAvatarStateMap,
isUrgent: Bool,
isDeletingAccount: Bool,
groupV2Params: GroupV2Params,
) async throws -> [Promise<Void>] {
guard let changeProto = changeResponse.groupChange else {
throw OWSAssertionError("Missing groupChange.")
}
guard changeProto.changeEpoch <= GroupManager.changeProtoEpoch else {
throw OWSAssertionError("Invalid embedded change proto epoch: \(changeProto.changeEpoch).")
}
let changeActionsProto = try GroupsV2Protos.parseGroupChangeProto(changeProto, verificationOperation: .alreadyTrusted)
let groupSendEndorsementsResponse = try changeResponse.groupSendEndorsementsResponse.map {
return try GroupSendEndorsementsResponse(contents: $0)
}
try await updateGroupWithChangeActions(
spamReportingMetadata: .learnedByLocallyInitatedRefresh,
changeActionsProto: changeActionsProto,
groupSendEndorsementsResponse: groupSendEndorsementsResponse,
justUploadedAvatars: justUploadedAvatars,
groupV2Params: groupV2Params,
)
switch messageBehavior {
case .sendNothing:
return []
case .sendUpdateToOtherGroupMembers:
break
}
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let groupChangeProtoData = try changeProto.serializedData()
var sendPromises = [Promise<Void>]()
sendPromises.append(await GroupManager.sendGroupUpdateMessage(
groupId: groupId,
isUrgent: isUrgent,
isDeletingAccount: isDeletingAccount,
groupChangeProtoData: groupChangeProtoData,
))
sendPromises.append(contentsOf: await sendGroupUpdateMessageToRemovedUsers(
changeActionsProto: changeActionsProto,
groupChangeProtoData: groupChangeProtoData,
groupV2Params: groupV2Params,
))
return sendPromises
}
private func membersRemovedByChangeActions(
groupChangeActionsProto: GroupsProtoGroupChangeActions,
groupV2Params: GroupV2Params,
) -> [ServiceId] {
var serviceIds = [ServiceId]()
for action in groupChangeActionsProto.deleteMembers {
guard let userId = action.deletedUserID else {
owsFailDebug("Missing userID.")
continue
}
do {
serviceIds.append(try groupV2Params.aci(for: userId))
} catch {
owsFailDebug("Error: \(error)")
}
}
for action in groupChangeActionsProto.deletePendingMembers {
guard let userId = action.deletedUserID else {
owsFailDebug("Missing userID.")
continue
}
do {
serviceIds.append(try groupV2Params.serviceId(for: userId))
} catch {
owsFailDebug("Error: \(error)")
}
}
for action in groupChangeActionsProto.deleteRequestingMembers {
guard let userId = action.deletedUserID else {
owsFailDebug("Missing userID.")
continue
}
do {
serviceIds.append(try groupV2Params.aci(for: userId))
} catch {
owsFailDebug("Error: \(error)")
}
}
return serviceIds
}
private func sendGroupUpdateMessageToRemovedUsers(
changeActionsProto: GroupsProtoGroupChangeActions,
groupChangeProtoData: Data,
groupV2Params: GroupV2Params,
) async -> [Promise<Void>] {
let serviceIds = membersRemovedByChangeActions(
groupChangeActionsProto: changeActionsProto,
groupV2Params: groupV2Params,
)
if serviceIds.isEmpty {
return []
}
let plaintextData: Data
let timestamp = MessageTimestampGenerator.sharedInstance.generateTimestamp()
do {
let groupV2Context = try GroupsV2Protos.buildGroupContextProto(
masterKey: groupV2Params.groupSecretParams.getMasterKey(),
revision: changeActionsProto.revision,
groupChangeProtoData: groupChangeProtoData,
)
let dataBuilder = SSKProtoDataMessage.builder()
dataBuilder.setGroupV2(groupV2Context)
dataBuilder.setRequiredProtocolVersion(UInt32(SSKProtoDataMessageProtocolVersion.initial.rawValue))
dataBuilder.setTimestamp(timestamp)
let dataProto = try dataBuilder.build()
let contentBuilder = SSKProtoContent.builder()
contentBuilder.setDataMessage(dataProto)
plaintextData = try contentBuilder.buildSerializedData()
} catch {
owsFailDebug("\(error)")
return [Promise(error: error)]
}
return await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
return serviceIds.map { serviceId in
let address = SignalServiceAddress(serviceId)
let contactThread = TSContactThread.getOrCreateThread(withContactAddress: address, transaction: tx)
let message = OutgoingStaticMessage(thread: contactThread, timestamp: timestamp, plaintextData: plaintextData, tx: tx)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: message,
)
return SSKEnvironment.shared.messageSenderJobQueueRef.add(.promise, message: preparedMessage, transaction: tx)
}
}
}
// This method can process protos from another client, so there's a possibility
// the serverGuid may be present and can be passed along to record with the update.
public func updateGroupWithChangeActions(
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSecretParams: GroupSecretParams,
) async throws {
let groupV2Params = try GroupV2Params(groupSecretParams: groupSecretParams)
try await _updateGroupWithChangeActions(
spamReportingMetadata: spamReportingMetadata,
changeActionsProto: changeActionsProto,
groupSendEndorsementsResponse: nil,
justUploadedAvatars: nil,
groupV2Params: groupV2Params,
)
}
private func updateGroupWithChangeActions(
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSendEndorsementsResponse: GroupSendEndorsementsResponse?,
justUploadedAvatars: GroupAvatarStateMap?,
groupV2Params: GroupV2Params,
) async throws {
try await _updateGroupWithChangeActions(
spamReportingMetadata: spamReportingMetadata,
changeActionsProto: changeActionsProto,
groupSendEndorsementsResponse: groupSendEndorsementsResponse,
justUploadedAvatars: justUploadedAvatars,
groupV2Params: groupV2Params,
)
}
private func _updateGroupWithChangeActions(
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSendEndorsementsResponse: GroupSendEndorsementsResponse?,
justUploadedAvatars: GroupAvatarStateMap?,
groupV2Params: GroupV2Params,
) async throws {
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let downloadedAvatars = try await fetchAllAvatarData(
changeActionsProtos: [changeActionsProto],
justUploadedAvatars: justUploadedAvatars,
groupV2Params: groupV2Params,
)
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
_ = try SSKEnvironment.shared.groupV2UpdatesRef.updateGroupWithChangeActions(
groupId: groupId,
spamReportingMetadata: spamReportingMetadata,
changeActionsProto: changeActionsProto,
groupSendEndorsementsResponse: groupSendEndorsementsResponse,
downloadedAvatars: downloadedAvatars,
transaction: tx,
)
}
}
// MARK: - Upload Avatar
public func uploadGroupAvatar(
avatarData: Data,
groupSecretParams: GroupSecretParams,
) async throws -> String {
let groupV2Params = try GroupV2Params(groupSecretParams: groupSecretParams)
return try await uploadGroupAvatar(avatarData: avatarData, groupV2Params: groupV2Params)
}
private func uploadGroupAvatar(
avatarData: Data,
groupV2Params: GroupV2Params,
) async throws -> String {
let requestBuilder: RequestBuilder = { authCredential in
try StorageService.buildGroupAvatarUploadFormRequest(
groupV2Params: groupV2Params,
authCredential: authCredential,
)
}
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: groupId,
behavior400: .fail,
behavior403: .fetchGroupUpdates,
behavior423: .ignore,
)
guard let protoData = response.responseBodyData else {
throw OWSAssertionError("Invalid responseObject.")
}
let avatarUploadAttributes = try GroupsProtoAvatarUploadAttributes(serializedData: protoData)
let uploadForm = try Upload.CDN0.Form.parse(proto: avatarUploadAttributes)
let encryptedData = try groupV2Params.encryptGroupAvatar(avatarData)
return try await Upload.CDN0.upload(data: encryptedData, uploadForm: uploadForm)
}
// MARK: - Fetch Current Group State
public func fetchLatestSnapshot(
secretParams: GroupSecretParams,
justUploadedAvatars: GroupAvatarStateMap?,
) async throws -> GroupV2SnapshotResponse {
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
return try await fetchLatestSnapshot(groupV2Params: groupV2Params, justUploadedAvatars: justUploadedAvatars)
}
private func fetchLatestSnapshot(
groupV2Params: GroupV2Params,
justUploadedAvatars: GroupAvatarStateMap?,
) async throws -> GroupV2SnapshotResponse {
let requestBuilder: RequestBuilder = { authCredential in
try StorageService.buildFetchCurrentGroupV2SnapshotRequest(
groupV2Params: groupV2Params,
authCredential: authCredential,
)
}
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: groupId,
behavior400: .fail,
behavior403: .removeFromGroup,
behavior423: .ignore,
)
let groupResponseProto = try GroupsProtoGroupResponse(serializedData: response.responseBodyData ?? Data())
let downloadedAvatars = try await fetchAllAvatarData(
groupProtos: [groupResponseProto.group].compacted(),
justUploadedAvatars: justUploadedAvatars,
groupV2Params: groupV2Params,
)
return try GroupsV2Protos.parse(
groupResponseProto: groupResponseProto,
downloadedAvatars: downloadedAvatars,
groupV2Params: groupV2Params,
)
}
// MARK: - Fetch Group Change Actions
/// Fetches some group changes (and a snapshot, if needed).
public func fetchSomeGroupChangeActions(
secretParams: GroupSecretParams,
source: GroupChangeActionFetchSource,
) async throws -> GroupChangesResponse {
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier().serialize()
let groupModel: TSGroupModelV2?
let gseExpiration: UInt64
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
(groupModel, gseExpiration) = databaseStorage.read { tx in
let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: tx)
let groupThreadId = groupThread?.sqliteRowId!
let endorsementRecord = groupThreadId.flatMap({ try? groupSendEndorsementStore.fetchCombinedEndorsement(groupThreadId: $0, tx: tx) })
return (
groupThread?.groupModel as? TSGroupModelV2,
endorsementRecord?.expirationTimestamp ?? 0,
)
}
// If we're fetching because we're processing messages, we can stop as soon
// as we have a new enough revision. (This can happen due to race
// conditions receiving messages & refreshing groups, though it generally
// won't happen because the message processing code only calls this if it
// believes the revision is too old.)
if
let groupModel,
case .groupMessage(let upThroughRevision) = source,
groupModel.revision >= upThroughRevision
{
// This is fine even if we're a requesting member b/c revision must
// increment for anything meaningful to happen.
return GroupChangesResponse(groupChanges: [], shouldFetchMore: false)
}
let upThroughRevision: UInt32?
switch source {
case .groupMessage(let revision):
upThroughRevision = revision
case .other:
upThroughRevision = nil
}
// We can process a change action to move from revision N to revision N + 1
// UNLESS we have a placeholder group. In that case, we don't actually have
// revision N -- we have an incomplete copy of revision N that must be made
// whole before we can apply a delta to it.
// If we're currently a full member, fetch the next batch.
if let groupModel, groupModel.groupMembership.isLocalUserFullMember {
// We're being told about a group we are aware of and are already a member
// of. In this case, we can figure out which revision we want to start with
// from local data.
let startingAtRevision: UInt32
let includeFirstState: Bool
switch source {
case .groupMessage:
startingAtRevision = groupModel.revision + 1
includeFirstState = false
case .other:
startingAtRevision = groupModel.revision
includeFirstState = true
}
do {
return try await _fetchSomeGroupChangeActions(
secretParams: secretParams,
startingAtRevision: startingAtRevision,
upThroughRevision: upThroughRevision,
includeFirstState: includeFirstState,
gseExpiration: gseExpiration,
)
} catch GroupsV2Error.localUserNotInGroup {
// If we can't fetch starting at the next version, we might have been
// removed and re-added, so we should figure out if we're back in the group
// at a later revision.
}
}
// Otherwise, we want to figure out where we got permission to start
// fetching changes, update to that via a snapshot, and then apply
// everything that follows.
let startingAtRevision = try await getRevisionLocalUserWasAddedToGroup(secretParams: secretParams)
return try await _fetchSomeGroupChangeActions(
secretParams: secretParams,
startingAtRevision: startingAtRevision,
upThroughRevision: upThroughRevision,
includeFirstState: true,
gseExpiration: gseExpiration,
)
}
private func _fetchSomeGroupChangeActions(
secretParams: GroupSecretParams,
startingAtRevision: UInt32,
upThroughRevision: UInt32?,
includeFirstState: Bool,
gseExpiration: UInt64,
) async throws -> GroupChangesResponse {
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
let limit: UInt32? = upThroughRevision.map({ (startingAtRevision <= $0) ? ($0 - startingAtRevision + 1) : 1 })
let response = try await performServiceRequest(
requestBuilder: { authCredential in
return try StorageService.buildFetchGroupChangeActionsRequest(
secretParams: secretParams,
fromRevision: startingAtRevision,
limit: limit,
includeFirstState: includeFirstState,
gseExpiration: gseExpiration,
authCredential: authCredential,
)
},
groupId: groupId,
behavior400: .fail,
behavior403: .ignore, // actually means "throw error"
behavior423: .ignore,
)
guard let groupChangesProtoData = response.responseBodyData else {
throw OWSAssertionError("Invalid responseObject.")
}
let earlyEnd: UInt32?
if response.responseStatusCode == 206 {
let groupRangeHeader = response.headers["content-range"]
earlyEnd = try Self.parseEarlyEnd(fromGroupRangeHeader: groupRangeHeader)
} else {
earlyEnd = nil
}
let groupChangesProto = try GroupsProtoGroupChanges(serializedData: groupChangesProtoData)
let parsedChanges = try GroupsV2Protos.parseChangesFromService(groupChangesProto: groupChangesProto)
let downloadedAvatars = try await fetchAllAvatarData(
groupProtos: parsedChanges.compactMap(\.groupProto),
changeActionsProtos: parsedChanges.compactMap(\.changeActionsProto),
groupV2Params: try GroupV2Params(groupSecretParams: secretParams),
)
let changes = try parsedChanges.map { parsedChange in
return GroupV2Change(
snapshot: try parsedChange.groupProto.map {
return try GroupsV2Protos.parse(
groupProto: $0,
fetchedAlongsideChangeActionsProto: parsedChange.changeActionsProto,
downloadedAvatars: downloadedAvatars,
groupV2Params: try GroupV2Params(groupSecretParams: secretParams),
)
},
changeActionsProto: parsedChange.changeActionsProto,
downloadedAvatars: downloadedAvatars,
)
}
let groupSendEndorsementsResponse = try groupChangesProto.groupSendEndorsementsResponse.map {
return try GroupSendEndorsementsResponse(contents: $0)
}
return GroupChangesResponse(
groupChanges: changes,
groupSendEndorsementsResponse: groupSendEndorsementsResponse,
shouldFetchMore: earlyEnd != nil && (upThroughRevision == nil || upThroughRevision! > earlyEnd!),
)
}
private static func parseEarlyEnd(fromGroupRangeHeader header: String?) throws -> UInt32 {
guard let header else {
throw OWSAssertionError("Missing Content-Range for group update request with 206 response")
}
let pattern = try! NSRegularExpression(pattern: #"^versions (\d+)-(\d+)/(\d+)$"#)
guard let match = pattern.firstMatch(in: header, range: header.entireRange) else {
throw OWSAssertionError("Couldn't parse Content-Range header: \(header)")
}
guard let earlyEndRange = Range(match.range(at: 1), in: header) else {
throw OWSAssertionError("Could not translate NSRange to Range<String.Index>")
}
guard let earlyEndValue = UInt32(header[earlyEndRange]) else {
throw OWSAssertionError("Invalid early-end in Content-Range for group update request: \(header)")
}
return earlyEndValue
}
private func getRevisionLocalUserWasAddedToGroup(secretParams: GroupSecretParams) async throws -> UInt32 {
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
let getJoinedAtRevisionRequestBuilder: RequestBuilder = { authCredential in
try StorageService.buildGetJoinedAtRevisionRequest(
secretParams: secretParams,
authCredential: authCredential,
)
}
// We might get a 403 if we are not a member of the group, e.g. if we are
// joining via invite link. Passing .ignore means we won't retry and will
// allow the "not a member" error to be thrown and propagated upwards.
let response = try await performServiceRequest(
requestBuilder: getJoinedAtRevisionRequestBuilder,
groupId: groupId,
behavior400: .fail,
behavior403: .ignore,
behavior423: .ignore,
)
guard let memberData = response.responseBodyData else {
throw OWSAssertionError("Response missing body data")
}
let memberProto = try GroupsProtoMember(serializedData: memberData)
return memberProto.joinedAtRevision
}
// MARK: - Avatar Downloads
// Before we can apply snapshots/changes from the service, we
// need to download all avatars they use. We can skip downloads
// in a couple of cases:
//
// * We just created the group.
// * We just updated the group and we're applying those changes.
private func fetchAllAvatarData(
groupProtos: [GroupsProtoGroup] = [],
changeActionsProtos: [GroupsProtoGroupChangeActions] = [],
justUploadedAvatars: GroupAvatarStateMap? = nil,
groupV2Params: GroupV2Params,
) async throws -> GroupAvatarStateMap {
var downloadedAvatars = GroupAvatarStateMap()
// Creating or updating a group is a multi-step process
// that can involve uploading an avatar, updating the
// group on the service, then updating the local database.
// We can skip downloading an avatar that we just uploaded
// using justUploadedAvatars.
if let justUploadedAvatars {
downloadedAvatars.merge(justUploadedAvatars)
}
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier().serialize()
// First step - try to skip downloading the current group avatar.
if
let groupThread = (SSKEnvironment.shared.databaseStorageRef.read { transaction in
return TSGroupThread.fetch(groupId: groupId, transaction: transaction)
}),
let groupModel = groupThread.groupModel as? TSGroupModelV2
{
// Try to add avatar from group model, if any.
downloadedAvatars.merge(GroupAvatarStateMap.from(groupModel: groupModel))
}
let protoAvatarUrlPaths = GroupsV2Protos.collectAvatarUrlPaths(
groupProtos: groupProtos,
changeActionsProtos: changeActionsProtos,
)
return try await fetchAvatarDataIfNotBlurred(
avatarUrlPaths: protoAvatarUrlPaths,
knownAvatarStates: downloadedAvatars,
groupV2Params: groupV2Params,
)
}
private func fetchAvatarDataIfNotBlurred(
avatarUrlPaths: [String],
knownAvatarStates: GroupAvatarStateMap,
groupV2Params: GroupV2Params,
) async throws -> GroupAvatarStateMap {
enum AvatarBlurRequirement {
case blurNotRequired
case blurRequired
case unknown
}
let avatarBlurRequirement: AvatarBlurRequirement = try DependenciesBridge.shared.db.read { tx in
let groupThread = TSGroupThread.fetch(
forGroupId: try groupV2Params.groupPublicParams.getGroupIdentifier(),
tx: tx,
)
guard let groupThread else {
// Don't download until we can confirm the group is trusted.
// Skip for now and try again after the thread is created.
return .unknown
}
let contactManager = SSKEnvironment.shared.contactManagerImplRef
let shouldBlockDownload = contactManager.shouldBlockAvatarDownload(
groupThread: groupThread,
tx: tx,
)
return shouldBlockDownload ? .blurRequired : .blurNotRequired
}
var downloadedAvatars = knownAvatarStates
switch avatarBlurRequirement {
case .blurNotRequired:
break
case .blurRequired:
let undownloadedAvatarUrlPaths = Set(avatarUrlPaths).subtracting(downloadedAvatars.avatarUrlPaths)
undownloadedAvatarUrlPaths.forEach { urlPath in
downloadedAvatars.set(avatarDataState: .lowTrustDownloadWasBlocked, avatarUrlPath: urlPath)
}
return downloadedAvatars
case .unknown:
// Don't download anything new and check again later
let undownloadedAvatarUrlPaths = Set(avatarUrlPaths).subtracting(downloadedAvatars.avatarUrlPaths)
undownloadedAvatarUrlPaths.forEach { urlPath in
downloadedAvatars.set(avatarDataState: .skipped, avatarUrlPath: urlPath)
}
return downloadedAvatars
}
downloadedAvatars.removeBlockedAvatars()
let undownloadedAvatarUrlPaths = Set(avatarUrlPaths).subtracting(downloadedAvatars.avatarUrlPaths)
try await withThrowingTaskGroup(of: (String, Data).self) { taskGroup in
// We need to "populate" any group changes that have a
// avatar with the avatar data.
for avatarUrlPath in undownloadedAvatarUrlPaths {
taskGroup.addTask {
var avatarData: Data
do {
avatarData = try await self.fetchAvatarData(
avatarUrlPath: avatarUrlPath,
groupV2Params: groupV2Params,
)
} catch OWSURLSessionError.responseTooLarge {
owsFailDebug("Had response-too-large fetching group avatar!")
avatarData = Data()
} catch where error.httpStatusCode == 404 {
// Fulfill with empty data if service returns 404 status code.
// We don't want the group to be left in an unrecoverable state
// if the avatar is missing from the CDN.
owsFailDebug("Had 404 fetching group avatar!")
avatarData = Data()
}
if !avatarData.isEmpty {
avatarData = (try? groupV2Params.decryptGroupAvatar(avatarData)) ?? Data()
}
return (avatarUrlPath, avatarData)
}
}
while let (avatarUrlPath, avatarData) = try await taskGroup.next() {
let avatarDataState: TSGroupModel.AvatarDataState
if
!avatarData.isEmpty,
TSGroupModel.isValidGroupAvatarData(avatarData)
{
avatarDataState = .available(avatarData)
} else {
avatarDataState = .failedToFetchFromCDN
}
downloadedAvatars.set(
avatarDataState: avatarDataState,
avatarUrlPath: avatarUrlPath,
)
}
}
return downloadedAvatars
}
let avatarDownloadQueue = ConcurrentTaskQueue(concurrentLimit: 3)
private func fetchAvatarData(
avatarUrlPath: String,
groupV2Params: GroupV2Params,
) async throws -> Data {
return try await avatarDownloadQueue.run {
// We throw away decrypted avatars larger than `kMaxEncryptedAvatarSize`.
return try await GroupsV2AvatarDownloadOperation.run(
urlPath: avatarUrlPath,
maxDownloadSize: kMaxEncryptedAvatarSize,
)
}
}
// MARK: - Generic Group Change
public func updateGroupV2(
secretParams: GroupSecretParams,
isDeletingAccount: Bool,
changesBlock: (GroupsV2OutgoingChanges) -> Void,
) async throws -> [Promise<Void>] {
let changes = GroupsV2OutgoingChanges(groupSecretParams: secretParams)
changesBlock(changes)
return try await updateExistingGroupOnService(changes: changes, isDeletingAccount: isDeletingAccount)
}
// MARK: - Rotate Profile Key
private let profileKeyUpdater: GroupsV2ProfileKeyUpdater
public func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: DBWriteTransaction) {
profileKeyUpdater.scheduleAllGroupsV2ForProfileKeyUpdate(transaction: transaction)
}
public func processProfileKeyUpdates() {
profileKeyUpdater.processProfileKeyUpdates()
}
public func updateLocalProfileKeyInGroup(groupId: Data, transaction: DBWriteTransaction) {
profileKeyUpdater.updateLocalProfileKeyInGroup(groupId: groupId, transaction: transaction)
}
// MARK: - Perform Request
private typealias RequestBuilder = (AuthCredentialWithPni) async throws -> GroupsV2Request
/// Represents how we should respond to 400 status codes.
enum Behavior400 {
case fail
case reportForRecovery
}
/// Represents how we should respond to 403 status codes.
private enum Behavior403 {
case fail
case removeFromGroup
case fetchGroupUpdates
case ignore
case reportInvalidOrBlockedGroupLink
case localUserIsNotARequestingMember
}
private enum Behavior423 {
case ignore
case reportTerminatedGroupLink
}
/// Make a request to the GV2 service, produced by the given
/// `requestBuilder`. Specifies how to respond if the request results in
/// certain errors.
private func performServiceRequest(
requestBuilder: RequestBuilder,
groupId: GroupIdentifier?,
behavior400: Behavior400,
behavior403: Behavior403,
behavior423: Behavior423,
) async throws -> HTTPResponse {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
throw OWSAssertionError("Missing localIdentifiers.")
}
return try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.httpStatusCode == 401 },
block: {
let authCredential = try await authCredentialManager.fetchGroupAuthCredential(localIdentifiers: localIdentifiers)
let request = try await requestBuilder(authCredential)
do {
return try await performServiceRequestAttempt(request: request)
} catch {
try await self.tryRecoveryFromServiceRequestFailure(
error: error,
groupId: groupId,
behavior400: behavior400,
behavior403: behavior403,
behavior423: behavior423,
)
}
},
)
}
/// Upon error from performing a service request, attempt to recover based
/// on the error and our 4XX behaviors.
private func tryRecoveryFromServiceRequestFailure(
error: Error,
groupId: GroupIdentifier?,
behavior400: Behavior400,
behavior403: Behavior403,
behavior423: Behavior423,
) async throws -> Never {
// Fall through to retry if retry-able,
// otherwise reject immediately.
if let statusCode = error.httpStatusCode {
switch statusCode {
case 400:
switch behavior400 {
case .fail:
owsFailDebug("Unexpected 400.")
case .reportForRecovery:
throw GroupsV2Error.serviceRequestHitRecoverable400
}
throw error
case 401:
// Retry auth errors after retrieving new temporal credentials.
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
self.authCredentialStore.removeAllGroupAuthCredentials(tx: tx)
}
throw error
case 403:
guard
let responseHeaders = error.httpResponseHeaders,
responseHeaders.hasValueForHeader("x-signal-timestamp")
else {
// The cloud infrastructure that sits in front of the Groups
// server is known to, in some situations, short-circuit
// requests with a 403 before they make it to a Signal
// server. That's a problem, since we might take destructive
// action locally in response to a 403. 403s from a Signal
// server will always contain this header; if we find one
// without, we can't trust it and should bail.
throw OWSAssertionError("Dropping 403 response without x-signal-timestamp header! \(error)")
}
// 403 indicates that we are no longer in the group for
// many (but not all) group v2 service requests.
switch behavior403 {
case .fail:
// We should never receive 403 when creating groups.
owsFailDebug("Unexpected 403.")
case .ignore:
// We may get a 403 when fetching change actions if
// they are not yet a member - for example, if they are
// joining via an invite link.
owsAssertDebug(groupId != nil, "Expecting a groupId for this path")
case .removeFromGroup:
guard let groupId else {
owsFailDebug("GroupId must be set to remove from group")
break
}
// If we receive 403 when trying to fetch group state, we have left the
// group, been removed from the group, or had our invite revoked, and we
// should make sure group state in the database reflects that.
await GroupManager.handleNotInGroup(groupId: groupId)
case .fetchGroupUpdates:
guard let groupId else {
owsFailDebug("GroupId must be set to fetch group updates")
break
}
// Service returns 403 if client tries to perform an
// update for which it is not authorized (e.g. add a
// new member if membership access is admin-only).
// The local client can't assume that 403 means they
// are not in the group. Therefore we "update group
// to latest" to check for and handle that case (see
// previous case).
self.tryToUpdateGroupToLatest(groupId: groupId)
case .reportInvalidOrBlockedGroupLink:
owsAssertDebug(groupId == nil, "groupId should not be set in this code path.")
if error.httpResponseHeaders?.containsBan == true {
throw GroupsV2Error.localUserBlockedFromJoining
} else {
throw GroupsV2Error.expiredGroupInviteLink
}
case .localUserIsNotARequestingMember:
owsAssertDebug(groupId == nil, "groupId should not be set in this code path.")
throw GroupsV2Error.localUserIsNotARequestingMember
}
throw GroupsV2Error.localUserNotInGroup
case 409:
// Group update conflict. The caller may be able to recover by
// retrying, using the change set and the most recent state
// from the service.
throw GroupsV2Error.conflictingChangeOnService
case 423:
switch behavior423 {
case .ignore:
break
case .reportTerminatedGroupLink:
throw GroupsV2Error.terminatedGroupInviteLink
}
fallthrough
default:
// Unexpected status code.
throw error
}
} else {
// Unexpected error.
throw error
}
}
private func performServiceRequestAttempt(request: GroupsV2Request) async throws -> HTTPResponse {
let urlSession = self.urlSession
let requestDescription = "G2 \(request.method) \(request.urlString)"
Logger.info("Sending… -> \(requestDescription)")
do {
let response = try await urlSession.performRequest(
request.urlString,
method: request.method,
headers: request.headers,
body: request.bodyData,
)
let statusCode = response.responseStatusCode
let hasValidStatusCode = [200, 206].contains(statusCode)
guard hasValidStatusCode else {
throw OWSAssertionError("Invalid status code: \(statusCode)")
}
// NOTE: responseObject may be nil; not all group v2 responses have bodies.
Logger.info("HTTP \(statusCode) <- \(requestDescription)")
return response
} catch {
if let statusCode = error.httpStatusCode {
Logger.warn("HTTP \(statusCode) <- \(requestDescription)")
} else {
Logger.warn("Failure. <- \(requestDescription): \(error)")
}
if case URLError.cancelled = error {
throw error
}
if error.isNetworkFailureOrTimeout {
throw error
}
// These status codes will be handled by performServiceRequest.
if let statusCode = error.httpStatusCode, [400, 401, 403, 404, 409, 423].contains(statusCode) {
throw error
}
owsFailDebug("Couldn't send request.")
throw error
}
}
private func tryToUpdateGroupToLatest(groupId: GroupIdentifier) {
guard
let groupThread = (SSKEnvironment.shared.databaseStorageRef.read { transaction in
TSGroupThread.fetch(forGroupId: groupId, tx: transaction)
})
else {
owsFailDebug("Missing group thread.")
return
}
SSKEnvironment.shared.groupV2UpdatesRef.refreshGroupUpThroughCurrentRevision(groupThread: groupThread, throttle: true)
}
// MARK: - GSEs
public func handleGroupSendEndorsementsResponse(
_ groupSendEndorsementsResponse: GroupSendEndorsementsResponse,
groupThreadId: Int64,
secretParams: GroupSecretParams,
membership: GroupMembership,
localAci: Aci,
tx: DBWriteTransaction,
) {
do {
let fullMembers = membership.fullMembers.compactMap(\.serviceId)
let receivedEndorsements = try groupSendEndorsementsResponse.receive(
groupMembers: fullMembers,
localUser: localAci,
groupParams: secretParams,
serverParams: GroupsV2Protos.serverPublicParams(),
)
let combinedEndorsement = receivedEndorsements.combinedEndorsement
var individualEndorsements = [(ServiceId, GroupSendEndorsement)]()
for (serviceId, individualEndorsement) in zip(fullMembers, receivedEndorsements.endorsements) {
if serviceId == localAci {
// Don't save our own endorsement. We should never use it.
continue
}
individualEndorsements.append((serviceId, individualEndorsement))
}
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
Logger.info("Received GSEs that expire at \(groupSendEndorsementsResponse.expiration) for \(groupId)")
let recipientFetcher = DependenciesBridge.shared.recipientFetcher
groupSendEndorsementStore.saveEndorsements(
groupThreadId: groupThreadId,
expiration: groupSendEndorsementsResponse.expiration,
combinedEndorsement: combinedEndorsement,
individualEndorsements: individualEndorsements.map { serviceId, endorsement in
return (recipientFetcher.fetchOrCreate(serviceId: serviceId, tx: tx).id, endorsement)
},
tx: tx,
)
} catch {
owsFailDebug("Couldn't receive GSEs: \(error)")
}
}
// MARK: - ProfileKeyCredentials
/// Fetches a profile key credential for each passed ACI.
///
/// If credentials aren't available, they are omitted from the result.
/// (Callers use the absence of a credential to invite a user to a group
/// rather than adding them directly.)
///
/// If a credential isn't available and the client believes it can fetch it
/// (i.e., it has a profile key for that user), it will try to do so. If
/// that fetch fails, this method will re-throw the fetch error.
///
/// - Parameter forceRefresh: If true, a new credential will be fetched for
/// every element of `acis` for which the client has a believed-to-be-valid
/// profile key. The result will contain only those new credentials (i.e.,
/// it will omit credentials for users with missing/incorrect profile keys).
/// (This handles situations where you have a cached credential the client
/// believes is valid but the server rejects. If you can't fetch a new
/// credential for that user, you'll fall back to an invite.)
public func loadProfileKeyCredentials(
for acis: [Aci],
forceRefresh: Bool,
) async throws -> [Aci: ExpiringProfileKeyCredential] {
var results = [Aci: ExpiringProfileKeyCredential]()
if !forceRefresh {
results.merge(loadValidProfileKeyCredentials(for: acis), uniquingKeysWith: { _, new in new })
}
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
let fetchedAcis = try await withThrowingTaskGroup(of: Aci?.self) { taskGroup in
for aciToFetch in Set(acis).subtracting(results.keys) {
taskGroup.addTask {
do {
var context = ProfileFetchContext()
context.mustFetchNewCredential = true
_ = try await profileFetcher.fetchProfile(for: aciToFetch, context: context)
return aciToFetch
} catch ProfileFetcherError.couldNotFetchCredential {
// this is fine
return nil
}
}
}
return try await taskGroup.reduce(into: [], { $0.append($1) }).compacted()
}
if !fetchedAcis.isEmpty {
results.merge(loadValidProfileKeyCredentials(for: fetchedAcis), uniquingKeysWith: { _, new in new })
}
return results
}
private func loadValidProfileKeyCredentials(for acis: some Sequence<Aci>) -> [Aci: ExpiringProfileKeyCredential] {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
return databaseStorage.read { transaction in
var credentialMap = [Aci: ExpiringProfileKeyCredential]()
for aci in acis {
do {
if
let credential = try SSKEnvironment.shared.versionedProfilesRef.validProfileKeyCredential(
for: aci,
transaction: transaction,
)
{
credentialMap[aci] = credential
}
} catch {
owsFailDebug("Error loading profile key credential: \(error)")
}
}
return credentialMap
}
}
public func hasProfileKeyCredential(
for aci: Aci,
transaction: DBReadTransaction,
) -> Bool {
do {
return try SSKEnvironment.shared.versionedProfilesRef.validProfileKeyCredential(
for: aci,
transaction: transaction,
) != nil
} catch let error {
owsFailDebug("Error getting profile key credential: \(error)")
return false
}
}
// MARK: - Restore Groups
public func isGroupKnownToStorageService(
groupModel: TSGroupModelV2,
transaction: DBReadTransaction,
) -> Bool {
GroupsV2Impl.isGroupKnownToStorageService(groupModel: groupModel, transaction: transaction)
}
public func groupRecordPendingStorageServiceRestore(
masterKeyData: Data,
transaction: DBReadTransaction,
) -> StorageServiceProtoGroupV2Record? {
GroupsV2Impl.enqueuedGroupRecordForRestore(masterKeyData: masterKeyData, transaction: transaction)
}
public func restoreGroupFromStorageServiceIfNecessary(
groupRecord: StorageServiceProtoGroupV2Record,
account: AuthedAccount,
transaction: DBWriteTransaction,
) {
GroupsV2Impl.enqueueGroupRestore(groupRecord: groupRecord, account: account, transaction: transaction)
}
// MARK: - Group Links
private let groupInviteLinkPreviewCache = LRUCache<Data, GroupInviteLinkPreview>(
maxSize: 5,
shouldEvacuateInBackground: true,
)
private func groupInviteLinkPreviewCacheKey(groupSecretParams: GroupSecretParams) -> Data {
return groupSecretParams.serialize()
}
public func cachedGroupInviteLinkPreview(groupSecretParams: GroupSecretParams) -> GroupInviteLinkPreview? {
let cacheKey = groupInviteLinkPreviewCacheKey(groupSecretParams: groupSecretParams)
return groupInviteLinkPreviewCache.object(forKey: cacheKey)
}
// inviteLinkPassword is not necessary if we're already a member or have a pending request.
public func fetchGroupInviteLinkPreview(
inviteLinkPassword: Data?,
groupSecretParams: GroupSecretParams,
) async throws -> GroupInviteLinkPreview {
let cacheKey = groupInviteLinkPreviewCacheKey(groupSecretParams: groupSecretParams)
let groupV2Params = try GroupV2Params(groupSecretParams: groupSecretParams)
let requestBuilder: RequestBuilder = { authCredential in
try StorageService.buildFetchGroupInviteLinkPreviewRequest(
inviteLinkPassword: inviteLinkPassword,
groupV2Params: groupV2Params,
authCredential: authCredential,
)
}
let behavior403: Behavior403 = (
inviteLinkPassword != nil
? .reportInvalidOrBlockedGroupLink
: .localUserIsNotARequestingMember,
)
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: nil,
behavior400: .fail,
behavior403: behavior403,
behavior423: .reportTerminatedGroupLink,
)
guard let protoData = response.responseBodyData else {
throw OWSAssertionError("Invalid responseObject.")
}
let groupInviteLinkPreview = try GroupsV2Protos.parseGroupInviteLinkPreview(protoData, groupV2Params: groupV2Params)
groupInviteLinkPreviewCache.setObject(groupInviteLinkPreview, forKey: cacheKey)
return groupInviteLinkPreview
}
public func fetchGroupInviteLinkPreviewAndRefreshGroup(
inviteLinkPassword: Data?,
groupSecretParams: GroupSecretParams,
) async throws -> GroupInviteLinkPreview {
do {
let groupInviteLinkPreview = try await fetchGroupInviteLinkPreview(inviteLinkPassword: inviteLinkPassword, groupSecretParams: groupSecretParams)
await updatePlaceholderGroupModelUsingInviteLinkPreview(
groupSecretParams: groupSecretParams,
isLocalUserRequestingMember: groupInviteLinkPreview.isLocalUserRequestingMember,
revision: groupInviteLinkPreview.revision,
)
return groupInviteLinkPreview
} catch {
if case GroupsV2Error.localUserIsNotARequestingMember = error {
await self.updatePlaceholderGroupModelUsingInviteLinkPreview(
groupSecretParams: groupSecretParams,
isLocalUserRequestingMember: false,
revision: nil,
)
}
throw error
}
}
public func fetchGroupInviteLinkAvatar(
avatarUrlPath: String,
groupSecretParams: GroupSecretParams,
) async throws -> Data {
let groupV2Params = try GroupV2Params(groupSecretParams: groupSecretParams)
// Group invite previews are either user-initiated, or shown in already-
// accepted conversations, so skip the `IfNotBlurred` check.
let encryptedData = try await fetchAvatarData(
avatarUrlPath: avatarUrlPath,
groupV2Params: groupV2Params,
)
if
let avatarData = try? groupV2Params.decryptGroupAvatar(encryptedData),
!avatarData.isEmpty
{
return avatarData
} else {
throw OWSAssertionError("Failed to decrypt group avatar.")
}
}
public func fetchGroupAvatarRestoredFromBackup(
groupModel: TSGroupModelV2,
avatarUrlPath: String,
) async throws -> TSGroupModel.AvatarDataState {
let groupV2Params = try GroupV2Params(groupSecretParams: groupModel.secretParams())
let downloadedAvatars = try await fetchAvatarDataIfNotBlurred(
avatarUrlPaths: [avatarUrlPath],
knownAvatarStates: GroupAvatarStateMap(),
groupV2Params: groupV2Params,
)
return downloadedAvatars.avatarDataState(for: avatarUrlPath)!
}
public func downloadAndApplyGroupAvatarIfSkipped(
_ secretParams: GroupSecretParams,
) async throws {
let db = SSKEnvironment.shared.databaseStorageRef
let groupId = try GroupV2Params(groupSecretParams: secretParams)
.groupPublicParams
.getGroupIdentifier()
let avatarUrlPath: String? = db.read { tx in
guard
let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx),
let groupModel = groupThread.groupModel as? TSGroupModelV2,
case .missing = groupModel.avatarDataState
else { return nil }
return groupModel.avatarUrlPath
}
guard let avatarUrlPath else { return }
let downloadedAvatars = try await fetchAvatarDataIfNotBlurred(
avatarUrlPaths: [avatarUrlPath],
knownAvatarStates: GroupAvatarStateMap(),
groupV2Params: try GroupV2Params(groupSecretParams: secretParams),
)
guard let newAvatarDataState = downloadedAvatars.avatarDataState(for: avatarUrlPath) else {
return
}
// Re-fetch the current thread state and save the new avatar
try await db.awaitableWrite { tx in
guard
let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: tx),
let groupModel = groupThread.groupModel as? TSGroupModelV2
else { return }
switch newAvatarDataState {
case .available(let avatarData):
try groupModel.persistAvatarData(avatarData)
case .failedToFetchFromCDN:
groupModel.avatarDataFailedToFetchFromCDN = true
case .lowTrustDownloadWasBlocked:
groupModel.lowTrustAvatarDownloadWasBlocked = true
case .missing, .skipped:
return
}
groupThread.update(with: groupModel, transaction: tx)
}
}
public func joinGroupViaInviteLink(
secretParams: GroupSecretParams,
inviteLinkPassword: Data,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
) async throws {
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else {
throw OWSAssertionError("Missing localAci.")
}
try await Retry.performWithBackoff(
maxAttempts: 5,
isRetryable: {
// If multiple people try to join a group at the same time, some of them
// may encounter HTTP 409 errors. If this happens, we should back off and
// try again. By also incorporating jitter, multiple clients should be able
// to avoid each others' requests when retrying.
//
// We retry the *entire* operation (are we a member? are we invited? can we
// join via the invite link?) because HTTP 409 conflicts indicate that the
// group state has changed, and those changes might add us to the group via
// some other mechanism.
if case GroupsV2Error.conflictingChangeOnService = $0 {
return true
}
return false
},
block: {
try await _joinGroupViaInviteLink(
secretParams: secretParams,
localIdentifiers: localIdentifiers,
inviteLinkPassword: inviteLinkPassword,
downloadedAvatar: downloadedAvatar,
)
},
)
}
private func _joinGroupViaInviteLink(
secretParams: GroupSecretParams,
localIdentifiers: LocalIdentifiers,
inviteLinkPassword: Data,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
) async throws {
// There are many edge cases around joining groups via invite links.
//
// * We might have previously been a member or not.
// * We might previously have requested to join and been denied.
// * The group might or might not already exist in the database.
// * We might already be a full member.
// * We might already have a pending invite (in which case we should
// accept that invite rather than request to join).
// * The invite link may have been rescinded.
// Fetch a preview before refreshing the group. If somebody adds us while
// we're trying to join, this ensures that we run into an HTTP 409 Conflict
// rather than an HTTP 400. If we fetch a preview after trying to join via
// "alternate means", then it's possible for us to be added after we try to
// refresh the group but before we fetch the invite link preview (and, more
// specifically, its revision). In this case, we may submit a request to
// join a group that we've already joined.
let inviteLinkPreview = try await fetchGroupInviteLinkPreview(
inviteLinkPassword: inviteLinkPassword,
groupSecretParams: secretParams,
)
do {
// Check if...
//
// * We're already in the group.
// * We already have a pending invite. If so, use it.
//
// Note: this will typically fail.
try await joinGroupViaInviteLinkUsingAlternateMeans(
secretParams: secretParams,
localIdentifiers: localIdentifiers,
)
} catch GroupsV2Error.localUserNotInGroup {
try await self.joinGroupViaInviteLinkUsingPatch(
inviteLinkPreview: inviteLinkPreview,
inviteLinkPassword: inviteLinkPassword,
secretParams: secretParams,
localIdentifiers: localIdentifiers,
downloadedAvatar: downloadedAvatar,
)
}
}
private func joinGroupViaInviteLinkUsingAlternateMeans(
secretParams: GroupSecretParams,
localIdentifiers: LocalIdentifiers,
) async throws {
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
// First try to fetch latest group state from service.
// This will fail for users trying to join via group link
// who are not yet in the group.
try await refreshGroupWithTimeout(secretParams: secretParams)
let groupThread = SSKEnvironment.shared.databaseStorageRef.read { tx in
return TSGroupThread.fetch(forGroupId: groupId, tx: tx)
}
guard let groupModelV2 = groupThread?.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid group model.")
}
let groupMembership = groupModelV2.groupMembership
if groupMembership.isFullMember(localIdentifiers.aci) || groupMembership.isRequestingMember(localIdentifiers.aci) {
// We're already in the group.
return
}
if groupMembership.isInvitedMember(localIdentifiers.aci) {
// We're already invited by ACI; try to join by accepting the invite.
// That will make us a full member; requesting to join via
// the invite link might make us a requesting member.
try await GroupManager.localAcceptInviteToGroupV2(groupModel: groupModelV2)
return
}
if let pni = localIdentifiers.pni, groupMembership.isInvitedMember(pni) {
// We're already invited by PNI; try to join by accepting the invite.
// That will make us a full member; requesting to join via
// the invite link might make us a requesting member.
try await GroupManager.localAcceptInviteToGroupV2(groupModel: groupModelV2)
return
}
throw GroupsV2Error.localUserNotInGroup
}
private func joinGroupViaInviteLinkUsingPatch(
inviteLinkPreview: GroupInviteLinkPreview,
inviteLinkPassword: Data,
secretParams: GroupSecretParams,
localIdentifiers: LocalIdentifiers,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
) async throws {
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
let revisionForPlaceholderModel: UInt32
if inviteLinkPreview.isLocalUserRequestingMember {
// Use the current revision when creating a placeholder group.
revisionForPlaceholderModel = inviteLinkPreview.revision
} else {
let joinMode: GroupLinkJoinMode
switch inviteLinkPreview.addFromInviteLinkAccess {
case .any:
joinMode = .asMember
case .administrator:
joinMode = .asRequestingMember
default:
throw OWSAssertionError("Invalid addFromInviteLinkAccess.")
}
let groupChangeProto = try await self.buildChangeActionsProtoToJoinGroupLink(
newRevision: inviteLinkPreview.revision + 1,
joinMode: joinMode,
secretParams: secretParams,
localIdentifiers: localIdentifiers,
)
let requestBuilder: RequestBuilder = { authCredential in
return try StorageService.buildUpdateGroupRequest(
groupChangeProto: groupChangeProto,
groupV2Params: try GroupV2Params(groupSecretParams: secretParams),
authCredential: authCredential,
groupInviteLinkPassword: inviteLinkPassword,
)
}
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: groupId,
behavior400: .fail,
behavior403: .reportInvalidOrBlockedGroupLink,
behavior423: .reportTerminatedGroupLink,
)
let changeResponse = try GroupsProtoGroupChangeResponse(serializedData: response.responseBodyData ?? Data())
guard let changeProto = changeResponse.groupChange else {
throw OWSAssertionError("Missing groupChange after updating group.")
}
switch joinMode {
case .asMember:
// The PATCH request that adds us to the group (as a full or requesting member)
// only return the "change actions" proto data, but not a full snapshot
// so we need to separately GET the latest group state and update the database.
//
// Download and update database with the group state.
try await SSKEnvironment.shared.groupV2UpdatesRef.refreshGroup(
secretParams: secretParams,
options: [.didJustAddSelfViaGroupLink],
)
_ = await GroupManager.sendGroupUpdateMessage(
groupId: groupId,
groupChangeProtoData: try changeProto.serializedData(),
)
return
case .asRequestingMember:
revisionForPlaceholderModel = groupChangeProto.revision
}
}
// We create a placeholder in a couple of different scenarios:
//
// * The GroupInviteLinkPreview indicates that we are already a requesting
// member of the group but the group does not yet exist in the database.
//
// * We successfully request to join a group via group invite link.
// Afterward we do not have access to group state on the service.
try await createPlaceholderGroupForJoinRequest(
inviteLinkPassword: inviteLinkPassword,
secretParams: secretParams,
localIdentifiers: localIdentifiers,
inviteLinkPreview: inviteLinkPreview,
downloadedAvatar: downloadedAvatar,
revisionForPlaceholderModel: revisionForPlaceholderModel,
)
}
private func createPlaceholderGroupForJoinRequest(
inviteLinkPassword: Data,
secretParams: GroupSecretParams,
localIdentifiers: LocalIdentifiers,
inviteLinkPreview: GroupInviteLinkPreview,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
revisionForPlaceholderModel revision: UInt32,
) async throws {
let groupId = try secretParams.getPublicParams().getGroupIdentifier()
let avatarUrlPath = inviteLinkPreview.avatarUrlPath
let avatarData: Data?
if let avatarUrlPath {
if let downloadedAvatar, downloadedAvatar.avatarUrlPath == avatarUrlPath {
avatarData = downloadedAvatar.avatarData
} else {
// We might fail to download the avatar. That's fine; this is just a
// placeholder model.
avatarData = try? await self.fetchGroupInviteLinkAvatar(avatarUrlPath: avatarUrlPath, groupSecretParams: secretParams)
}
} else {
avatarData = nil
}
// We might be creating a placeholder for a revision that we just
// created or for one we learned about from a GroupInviteLinkPreview.
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction throws -> Void in
if let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: transaction) {
// The group already existing in the database; make sure
// that we are a requesting member.
guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid groupModel.")
}
let oldGroupMembership = oldGroupModel.groupMembership
if oldGroupModel.revision >= revision, oldGroupMembership.isRequestingMember(localIdentifiers.aci) {
// No need to update database, group state is already acceptable.
return
}
var builder = oldGroupModel.asBuilder
builder.isJoinRequestPlaceholder = true
builder.groupV2Revision = max(revision, oldGroupModel.revision)
var membershipBuilder = oldGroupMembership.asBuilder
membershipBuilder.remove(localIdentifiers.aci)
membershipBuilder.addRequestingMember(localIdentifiers.aci)
builder.groupMembership = membershipBuilder.build()
let newGroupModel = try builder.build()
groupThread.update(with: newGroupModel, transaction: transaction)
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction).asToken
GroupManager.insertGroupUpdateInfoMessage(
groupThread: groupThread,
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
oldDisappearingMessageToken: dmToken,
newDisappearingMessageToken: dmToken,
newlyLearnedPniToAciAssociations: [:],
groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: transaction,
)
} else {
// Create a placeholder group.
var builder = TSGroupModelBuilder(secretParams: secretParams)
builder.name = inviteLinkPreview.title
builder.descriptionText = inviteLinkPreview.descriptionText
builder.groupAccess = GroupAccess(
members: GroupAccess.defaultForV2.members,
attributes: GroupAccess.defaultForV2.attributes,
addFromInviteLink: inviteLinkPreview.addFromInviteLinkAccess,
memberLabels: GroupAccess.defaultForV2.memberLabels,
)
builder.groupV2Revision = revision
builder.inviteLinkPassword = inviteLinkPassword
builder.isJoinRequestPlaceholder = true
builder.avatarUrlPath = inviteLinkPreview.avatarUrlPath
builder.avatarDataState = TSGroupModel.AvatarDataState(avatarData: avatarData)
var membershipBuilder = GroupMembership.Builder()
membershipBuilder.addRequestingMember(localIdentifiers.aci)
builder.groupMembership = membershipBuilder.build()
let groupModel = try builder.buildAsV2()
let groupThread = DependenciesBridge.shared.threadStore.createGroupThread(
groupModel: groupModel,
tx: transaction,
)
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction).asToken
GroupManager.insertGroupUpdateInfoMessageForNewGroup(
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
groupThread: groupThread,
groupModel: groupModel,
disappearingMessageToken: dmToken,
groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
transaction: transaction,
)
}
}
}
private enum GroupLinkJoinMode {
case asMember
case asRequestingMember
}
private func buildChangeActionsProtoToJoinGroupLink(
newRevision: UInt32,
joinMode: GroupLinkJoinMode,
secretParams: GroupSecretParams,
localIdentifiers: LocalIdentifiers,
) async throws -> GroupsProtoGroupChangeActions {
let localAci = localIdentifiers.aci
let profileKeyCredentialMap = try await loadProfileKeyCredentials(for: [localAci], forceRefresh: false)
guard let localProfileKeyCredential = profileKeyCredentialMap[localAci] else {
throw OWSAssertionError("Missing localProfileKeyCredential.")
}
var actionsBuilder = GroupsProtoGroupChangeActions.builder()
actionsBuilder.setRevision(newRevision)
switch joinMode {
case .asMember:
let role = TSGroupMemberRole.`normal`
var actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder()
actionBuilder.setAdded(
try GroupsV2Protos.buildMemberProto(
profileKeyCredential: localProfileKeyCredential,
role: role.asProtoRole,
groupV2Params: try GroupV2Params(groupSecretParams: secretParams),
),
)
actionsBuilder.addAddMembers(actionBuilder.buildInfallibly())
case .asRequestingMember:
var actionBuilder = GroupsProtoGroupChangeActionsAddRequestingMemberAction.builder()
actionBuilder.setAdded(
try GroupsV2Protos.buildRequestingMemberProto(
profileKeyCredential: localProfileKeyCredential,
groupV2Params: try GroupV2Params(groupSecretParams: secretParams),
),
)
actionsBuilder.addAddRequestingMembers(actionBuilder.buildInfallibly())
}
return actionsBuilder.buildInfallibly()
}
public func cancelRequestToJoin(groupModel: TSGroupModelV2) async throws {
let groupV2Params = try groupModel.groupV2Params()
var newRevision: UInt32?
do {
newRevision = try await cancelRequestToJoinUsingPatch(groupV2Params: groupV2Params)
} catch {
switch error {
case GroupsV2Error.localUserIsNotARequestingMember:
// In both of these cases, our request has already been removed. We can proceed with updating the model.
break
default:
// Otherwise, we don't recover and let the error propogate
throw error
}
}
try await updateGroupRemovingMemberRequest(groupId: groupModel.groupId, newRevision: newRevision)
}
private func updateGroupRemovingMemberRequest(
groupId: Data,
newRevision proposedRevision: UInt32?,
) async throws {
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction -> Void in
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction) else {
throw OWSAssertionError("Missing localIdentifiers.")
}
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Missing groupThread.")
}
// The group already existing in the database; make sure
// that we are a requesting member.
guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid groupModel.")
}
let oldGroupMembership = oldGroupModel.groupMembership
var newRevision = oldGroupModel.revision + 1
if let proposedRevision {
if oldGroupModel.revision >= proposedRevision {
// No need to update database, group state is already acceptable.
return
}
newRevision = proposedRevision
}
var builder = oldGroupModel.asBuilder
builder.isJoinRequestPlaceholder = true
builder.groupV2Revision = newRevision
var membershipBuilder = oldGroupMembership.asBuilder
membershipBuilder.remove(localIdentifiers.aci)
builder.groupMembership = membershipBuilder.build()
let newGroupModel = try builder.build()
groupThread.update(with: newGroupModel, transaction: transaction)
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction).asToken
GroupManager.insertGroupUpdateInfoMessage(
groupThread: groupThread,
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
oldDisappearingMessageToken: dmToken,
newDisappearingMessageToken: dmToken,
newlyLearnedPniToAciAssociations: [:],
groupUpdateSource: .localUser(originalSource: .aci(localIdentifiers.aci)),
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: transaction,
)
}
}
private func cancelRequestToJoinUsingPatch(groupV2Params: GroupV2Params) async throws -> UInt32 {
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
// We re-fetch the GroupInviteLinkPreview before trying in order to get the latest:
//
// * revision
// * addFromInviteLinkAccess
// * local user's request status.
let groupInviteLinkPreview = try await fetchGroupInviteLinkPreview(
inviteLinkPassword: nil,
groupSecretParams: groupV2Params.groupSecretParams,
)
let oldRevision = groupInviteLinkPreview.revision
let newRevision = oldRevision + 1
let requestBuilder: RequestBuilder = { authCredential in
let groupChangeProto = try self.buildChangeActionsProtoToCancelMemberRequest(
groupV2Params: groupV2Params,
newRevision: newRevision,
)
return try StorageService.buildUpdateGroupRequest(
groupChangeProto: groupChangeProto,
groupV2Params: groupV2Params,
authCredential: authCredential,
groupInviteLinkPassword: nil,
)
}
_ = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: groupId,
behavior400: .fail,
behavior403: .fail,
behavior423: .reportTerminatedGroupLink,
)
return newRevision
}
private func buildChangeActionsProtoToCancelMemberRequest(
groupV2Params: GroupV2Params,
newRevision: UInt32,
) throws -> GroupsProtoGroupChangeActions {
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
throw OWSAssertionError("Missing localAci.")
}
var actionsBuilder = GroupsProtoGroupChangeActions.builder()
actionsBuilder.setRevision(newRevision)
var actionBuilder = GroupsProtoGroupChangeActionsDeleteRequestingMemberAction.builder()
let userId = try groupV2Params.userId(for: localAci)
actionBuilder.setDeletedUserID(userId)
actionsBuilder.addDeleteRequestingMembers(actionBuilder.buildInfallibly())
return actionsBuilder.buildInfallibly()
}
private func updatePlaceholderGroupModelUsingInviteLinkPreview(
groupSecretParams: GroupSecretParams,
isLocalUserRequestingMember: Bool,
revision: UInt32?,
) async {
do {
let groupId = try groupSecretParams.getPublicParams().getGroupIdentifier()
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction) else {
throw OWSAssertionError("Missing localIdentifiers.")
}
guard let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: transaction) else {
// Thread not yet in database.
return
}
guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid groupModel.")
}
guard oldGroupModel.isJoinRequestPlaceholder else {
// Not a placeholder model; no need to update.
return
}
let oldGroupMembership = oldGroupModel.groupMembership
guard isLocalUserRequestingMember != oldGroupMembership.isLocalUserRequestingMember else {
// Nothing to change.
return
}
var builder = oldGroupModel.asBuilder
builder.isJoinRequestPlaceholder = true
if let revision {
builder.groupV2Revision = max(revision, builder.groupV2Revision)
}
var membershipBuilder = oldGroupMembership.asBuilder
membershipBuilder.remove(localIdentifiers.aci)
if isLocalUserRequestingMember {
membershipBuilder.addRequestingMember(localIdentifiers.aci)
}
builder.groupMembership = membershipBuilder.build()
let newGroupModel = try builder.build()
groupThread.update(with: newGroupModel, transaction: transaction)
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction).asToken
// groupUpdateSource is unknown; we don't know who did the update.
GroupManager.insertGroupUpdateInfoMessage(
groupThread: groupThread,
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
oldDisappearingMessageToken: dmToken,
newDisappearingMessageToken: dmToken,
newlyLearnedPniToAciAssociations: [:],
groupUpdateSource: .unknown,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
transaction: transaction,
)
}
} catch {
owsFailDebug("Error: \(error)")
}
}
public func fetchGroupExternalCredentials(secretParams: GroupSecretParams) async throws -> GroupsProtoGroupExternalCredential {
let groupParams = try GroupV2Params(groupSecretParams: secretParams)
let requestBuilder: RequestBuilder = { authCredential in
try StorageService.buildFetchGroupExternalCredentials(
groupV2Params: groupParams,
authCredential: authCredential,
)
}
let response = try await performServiceRequest(
requestBuilder: requestBuilder,
groupId: try secretParams.getPublicParams().getGroupIdentifier(),
behavior400: .fail,
behavior403: .fetchGroupUpdates,
behavior423: .ignore,
)
guard let groupProtoData = response.responseBodyData else {
throw OWSAssertionError("Invalid responseObject.")
}
return try GroupsProtoGroupExternalCredential(serializedData: groupProtoData)
}
}
private extension HttpHeaders {
private static let forbiddenKey: String = "X-Signal-Forbidden-Reason"
private static let forbiddenValue: String = "banned"
var containsBan: Bool {
value(forHeader: Self.forbiddenKey) == Self.forbiddenValue
}
}
// MARK: - What's in the change actions?
private extension GroupsProtoGroupChangeActions {
var containsProfileKeyCredentials: Bool {
// When adding a member, we include their profile key credential.
let isAddingMembers = !addMembers.isEmpty
// When promoting an invited member, we include the profile key for
// their ACI.
// Note: in practice the only user we'll promote is ourself, when
// accepting an invite.
let isPromotingPni = !promotePniPendingMembers.isEmpty
let isPromotingAci = !promotePendingMembers.isEmpty
return isAddingMembers || isPromotingPni || isPromotingAci
}
}