2276 lines
95 KiB
Swift
2276 lines
95 KiB
Swift
//
|
|
// 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, isLeavingGroup: Bool) 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,
|
|
options: isLeavingGroup ? [.leavingGroup] : [],
|
|
)
|
|
}
|
|
} 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,
|
|
isLeavingGroup: changes.shouldLeaveGroupDeclineInvite,
|
|
)
|
|
|
|
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 change actions starting at revision 0.
|
|
/// Checking the author of the first change can determine in most cases if the local user was the creator of the group.
|
|
/// If a GroupsV2.localUserNotInGroup error is thrown fetching the first change, the local user either did not create the group, or has since left
|
|
/// and rejoined.
|
|
public func fetchRevisionZeroGroupChangeAction(secretParams: GroupSecretParams) async throws -> GroupV2Change {
|
|
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
|
|
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier().serialize()
|
|
let gseExpiration: UInt64
|
|
|
|
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
|
|
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 endorsementRecord?.expirationTimestamp ?? 0
|
|
}
|
|
|
|
let groupChangesResponse = try await _fetchSomeGroupChangeActions(
|
|
secretParams: secretParams,
|
|
startingAtRevision: 0,
|
|
upThroughRevision: 0,
|
|
includeFirstState: true,
|
|
gseExpiration: gseExpiration,
|
|
)
|
|
|
|
guard let first = groupChangesResponse.groupChanges.first else {
|
|
throw OWSAssertionError("Missing first group change action")
|
|
}
|
|
return first
|
|
}
|
|
|
|
/// 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, isLeavingGroup: false)
|
|
|
|
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
|
|
}
|
|
}
|