Path: blob/main/SignalServiceKit/Groups/GroupsV2.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
public enum GroupsV2Error: Error {
/// The change we attempted conflicts with what is on the service.
case conflictingChangeOnService
case timeout
case localUserNotInGroup
case cannotBuildGroupChangeProto_conflictingChange
case cannotBuildGroupChangeProto_tooManyMembers
case localUserIsNotARequestingMember
case cantApplyChangesToPlaceholder
case expiredGroupInviteLink
case terminatedGroupInviteLink
case groupBlocked
case localUserBlockedFromJoining
/// We tried to apply an incremental group change proto but failed due to
/// an incompatible revision in the proto.
///
/// Note that group change protos can only be applied if they are a
/// continuous incremental update, i.e. our local revision is N and the
/// proto represents revision N+1.
case groupChangeProtoForIncompatibleRevision
/// We hit a 400 while making a service request, but believe it may be
/// recoverable.
case serviceRequestHitRecoverable400
/// When restoring from storage service, we want to skip terminated groups.
case skipRestoringTerminatedGroup
}
// MARK: -
@objc
public enum GroupsV2LinkMode: UInt, CustomStringConvertible {
case disabled
case enabledWithoutApproval
case enabledWithApproval
public var description: String {
switch self {
case .disabled:
return ".disabled"
case .enabledWithoutApproval:
return ".enabledWithoutApproval"
case .enabledWithApproval:
return ".enabledWithApproval"
}
}
}
// MARK: -
public protocol GroupsV2 {
func hasProfileKeyCredential(
for aci: Aci,
transaction: DBReadTransaction,
) -> Bool
func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: DBWriteTransaction)
func processProfileKeyUpdates()
func updateLocalProfileKeyInGroup(groupId: Data, transaction: DBWriteTransaction)
func isGroupKnownToStorageService(
groupModel: TSGroupModelV2,
transaction: DBReadTransaction,
) -> Bool
func createNewGroupOnService(
_ newGroup: GroupsV2Protos.NewGroupParams,
downloadedAvatars: GroupAvatarStateMap,
localAci: Aci,
) async throws -> GroupV2SnapshotResponse
func loadProfileKeyCredentials(
for acis: [Aci],
forceRefresh: Bool,
) async throws -> [Aci: ExpiringProfileKeyCredential]
func fetchLatestSnapshot(
secretParams: GroupSecretParams,
justUploadedAvatars: GroupAvatarStateMap?,
) async throws -> GroupV2SnapshotResponse
/// - Returns: A list of Promises for sending the group update message(s).
/// Each Promise represents sending a message to one or more recipients.
func updateGroupV2(
secretParams: GroupSecretParams,
isDeletingAccount: Bool,
changesBlock: (GroupsV2OutgoingChanges) -> Void,
) async throws -> [Promise<Void>]
func updateGroupWithChangeActions(
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSecretParams: GroupSecretParams,
) async throws
func uploadGroupAvatar(avatarData: Data, groupSecretParams: GroupSecretParams) async throws -> String
func cachedGroupInviteLinkPreview(groupSecretParams: GroupSecretParams) -> GroupInviteLinkPreview?
// inviteLinkPassword is not necessary if we're already a member or have a pending request.
func fetchGroupInviteLinkPreview(
inviteLinkPassword: Data?,
groupSecretParams: GroupSecretParams,
) async throws -> GroupInviteLinkPreview
func fetchGroupInviteLinkPreviewAndRefreshGroup(
inviteLinkPassword: Data?,
groupSecretParams: GroupSecretParams,
) async throws -> GroupInviteLinkPreview
func fetchGroupInviteLinkAvatar(
avatarUrlPath: String,
groupSecretParams: GroupSecretParams,
) async throws -> Data
func fetchGroupAvatarRestoredFromBackup(
groupModel: TSGroupModelV2,
avatarUrlPath: String,
) async throws -> TSGroupModel.AvatarDataState
func downloadAndApplyGroupAvatarIfSkipped(_ secretParams: GroupSecretParams) async throws
func joinGroupViaInviteLink(
secretParams: GroupSecretParams,
inviteLinkPassword: Data,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
) async throws
func cancelRequestToJoin(groupModel: TSGroupModelV2) async throws
func fetchGroupExternalCredentials(secretParams: GroupSecretParams) async throws -> GroupsProtoGroupExternalCredential
func groupRecordPendingStorageServiceRestore(
masterKeyData: Data,
transaction: DBReadTransaction,
) -> StorageServiceProtoGroupV2Record?
func restoreGroupFromStorageServiceIfNecessary(
groupRecord: StorageServiceProtoGroupV2Record,
account: AuthedAccount,
transaction: DBWriteTransaction,
)
func fetchSomeGroupChangeActions(
secretParams: GroupSecretParams,
source: GroupChangeActionFetchSource,
) async throws -> GroupChangesResponse
func handleGroupSendEndorsementsResponse(
_ groupSendEndorsementsResponse: GroupSendEndorsementsResponse,
groupThreadId: Int64,
secretParams: GroupSecretParams,
membership: GroupMembership,
localAci: Aci,
tx: DBWriteTransaction,
)
}
// MARK: -
/// Represents what we should do with regards to messages updating the other
/// members of the group about this change, if it successfully applied on
/// the service.
enum GroupUpdateMessageBehavior {
/// Send a group update message to all other group members.
case sendUpdateToOtherGroupMembers
/// Do not send any group update messages.
case sendNothing
}
// MARK: -
public enum GroupChangeActionFetchSource {
/// We're fetching group change actions while processing an incoming
/// message. We need to update to `revision` immediately and then stop
/// applying updates. (We expect future updates will be applied by messages
/// we've received but haven't yet processed.)
case groupMessage(revision: UInt32)
/// We're fetching group change actions for some other reason (e.g., we just
/// opened the group). We want to ensure we have the latest state, but we
/// don't want to update until we've had a chance to finish processing any
/// messages that might update the group.
case other
}
// MARK: -
/// Represents a constructed group change, ready to be sent to the service.
public struct GroupsV2BuiltGroupChange {
let proto: GroupsProtoGroupChangeActions
let groupUpdateMessageBehavior: GroupUpdateMessageBehavior
}
// MARK: -
public protocol GroupV2Updates {
func autoRefreshGroup() async throws(CancellationError)
func refreshGroupImpl(
secretParams: GroupSecretParams,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
source: GroupChangeActionFetchSource,
options: TSGroupModelOptions,
) async throws
func updateGroupWithChangeActions(
groupId: GroupIdentifier,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSendEndorsementsResponse: GroupSendEndorsementsResponse?,
downloadedAvatars: GroupAvatarStateMap,
transaction: DBWriteTransaction,
) throws -> TSGroupThread
func fetchAndApplyCurrentGroupV2SnapshotFromService(
secretParams: GroupSecretParams,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
options: TSGroupModelOptions,
skipTerminatedGroup: Bool,
) async throws
}
extension GroupV2Updates where Self: Sendable {
public func refreshGroup(
secretParams: GroupSecretParams,
spamReportingMetadata: GroupUpdateSpamReportingMetadata = .learnedByLocallyInitatedRefresh,
source: GroupChangeActionFetchSource = .other,
options: TSGroupModelOptions = [],
) async throws {
return try await refreshGroupImpl(
secretParams: secretParams,
spamReportingMetadata: spamReportingMetadata,
source: source,
options: options,
)
}
public func refreshGroupUpThroughCurrentRevision(groupThread: TSGroupThread, throttle: Bool) {
refreshGroupUpThroughCurrentRevision(groupThread: groupThread, options: throttle ? [.throttle] : [])
}
private func refreshGroupUpThroughCurrentRevision(groupThread: TSGroupThread, options: TSGroupModelOptions) {
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
return
}
let groupSecretParamsData = groupModel.secretParamsData
Task {
do {
try await self.refreshGroupImpl(
secretParams: try GroupSecretParams(contents: groupSecretParamsData),
spamReportingMetadata: .learnedByLocallyInitatedRefresh,
source: .other,
options: options,
)
} catch {
Logger.warn("Group refresh failed: \(error).")
}
}
}
}
// MARK: -
public struct GroupChangesResponse {
var groupChanges: [GroupV2Change]
var groupSendEndorsementsResponse: GroupSendEndorsementsResponse?
var shouldFetchMore: Bool
}
public struct GroupV2Change {
public var snapshot: GroupV2Snapshot?
public var changeActionsProto: GroupsProtoGroupChangeActions?
public let downloadedAvatars: GroupAvatarStateMap
public init(
snapshot: GroupV2Snapshot?,
changeActionsProto: GroupsProtoGroupChangeActions?,
downloadedAvatars: GroupAvatarStateMap,
) {
owsPrecondition(snapshot != nil || changeActionsProto != nil)
self.snapshot = snapshot
self.changeActionsProto = changeActionsProto
self.downloadedAvatars = downloadedAvatars
}
public var revision: UInt32 {
return changeActionsProto?.revision ?? snapshot!.revision
}
}
// MARK: -
extension GroupMasterKey {
static func isValid(_ masterKeyData: Data) -> Bool {
return (try? GroupMasterKey(contents: masterKeyData)) != nil
}
}
// MARK: -
public struct GroupV2ContextInfo {
public let masterKeyData: Data
public let groupSecretParams: GroupSecretParams
public let groupSecretParamsData: Data
public let groupId: GroupIdentifier
public static func deriveFrom(masterKeyData: Data) throws -> GroupV2ContextInfo {
let groupSecretParams = try self.groupSecretParams(for: masterKeyData)
let groupIdentifier = try groupSecretParams.getPublicParams().getGroupIdentifier()
return GroupV2ContextInfo(
masterKeyData: masterKeyData,
groupSecretParams: groupSecretParams,
groupId: groupIdentifier,
)
}
private static func groupSecretParams(for masterKeyData: Data) throws -> GroupSecretParams {
let groupMasterKey = try GroupMasterKey(contents: masterKeyData)
return try GroupSecretParams.deriveFromMasterKey(groupMasterKey: groupMasterKey)
}
private init(masterKeyData: Data, groupSecretParams: GroupSecretParams, groupId: GroupIdentifier) {
self.masterKeyData = masterKeyData
self.groupSecretParams = groupSecretParams
self.groupSecretParamsData = groupSecretParams.serialize()
self.groupId = groupId
}
}
// MARK: -
public struct GroupInviteLinkInfo: Hashable {
public let masterKey: Data
public let inviteLinkPassword: Data
public init(masterKey: Data, inviteLinkPassword: Data) {
self.masterKey = masterKey
self.inviteLinkPassword = inviteLinkPassword
}
public static func parseFrom(_ url: URL) -> GroupInviteLinkInfo? {
guard GroupManager.isPossibleGroupInviteLink(url) else {
return nil
}
guard let protoBase64Url = url.fragment, !protoBase64Url.isEmpty else {
owsFailDebug("Missing encoded data.")
return nil
}
do {
let protoData = try Data.data(fromBase64Url: protoBase64Url)
let proto = try GroupsProtoGroupInviteLink(serializedData: protoData)
guard let protoContents = proto.contents else {
owsFailDebug("Missing proto contents.")
return nil
}
switch protoContents {
case .contentsV1(let contentsV1):
guard let masterKey = contentsV1.groupMasterKey, !masterKey.isEmpty else {
owsFailDebug("Invalid masterKey.")
return nil
}
guard let inviteLinkPassword = contentsV1.inviteLinkPassword, !inviteLinkPassword.isEmpty else {
owsFailDebug("Invalid inviteLinkPassword.")
return nil
}
return GroupInviteLinkInfo(masterKey: masterKey, inviteLinkPassword: inviteLinkPassword)
}
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
}
// MARK: -
public struct GroupInviteLinkPreview: Equatable {
public let title: String
public let descriptionText: String?
public let avatarUrlPath: String?
public let memberCount: UInt32
public let addFromInviteLinkAccess: GroupV2Access
public let revision: UInt32
public let isLocalUserRequestingMember: Bool
}
// MARK: -
public struct GroupAvatarStateMap {
typealias AvatarDataState = TSGroupModel.AvatarDataState
private var avatarMap = [String: AvatarDataState]()
init() {}
mutating func set(avatarDataState: AvatarDataState, avatarUrlPath: String) {
avatarMap[avatarUrlPath] = avatarDataState
}
mutating func merge(_ other: GroupAvatarStateMap) {
for (avatarUrlPath, avatarDataState) in other.avatarMap {
avatarMap[avatarUrlPath] = avatarDataState
}
}
/// Remove all `AvatarDataState`s marked `.lowTrustDownloadWasBlocked`.
mutating func removeBlockedAvatars() {
avatarMap = avatarMap.filter { _, value in
switch value {
case .available, .failedToFetchFromCDN, .missing:
true
case .lowTrustDownloadWasBlocked, .skipped:
false
}
}
}
func avatarDataState(for avatarUrlPath: String) -> AvatarDataState? {
return avatarMap[avatarUrlPath]
}
var avatarUrlPaths: [String] {
return Array(avatarMap.keys)
}
static func from(groupModel: TSGroupModelV2) -> GroupAvatarStateMap {
return from(
avatarDataState: groupModel.avatarDataState,
avatarUrlPath: groupModel.avatarUrlPath,
)
}
static func from(changes: GroupsV2OutgoingChanges) -> GroupAvatarStateMap {
return from(
avatarDataState: AvatarDataState(avatarData: changes.newAvatarData),
avatarUrlPath: changes.newAvatarUrlPath,
)
}
private static func from(avatarDataState: AvatarDataState, avatarUrlPath: String?) -> GroupAvatarStateMap {
var downloadedAvatars = GroupAvatarStateMap()
guard let avatarUrlPath else {
return downloadedAvatars
}
downloadedAvatars.set(avatarDataState: avatarDataState, avatarUrlPath: avatarUrlPath)
return downloadedAvatars
}
}
// MARK: -
public struct InvalidInvite: Equatable {
public let userId: Data
public let addedByUserId: Data
public init(userId: Data, addedByUserId: Data) {
self.userId = userId
self.addedByUserId = addedByUserId
}
}
// MARK: -
public class MockGroupsV2: GroupsV2 {
public func createNewGroupOnService(
_ newGroup: GroupsV2Protos.NewGroupParams,
downloadedAvatars: GroupAvatarStateMap,
localAci: Aci,
) async throws -> GroupV2SnapshotResponse {
owsFail("Not implemented.")
}
public func hasProfileKeyCredential(
for aci: Aci,
transaction: DBReadTransaction,
) -> Bool {
owsFail("Not implemented.")
}
public func loadProfileKeyCredentials(
for acis: [Aci],
forceRefresh: Bool,
) async throws -> [Aci: ExpiringProfileKeyCredential] {
owsFail("Not implemented.")
}
public func fetchLatestSnapshot(
secretParams: GroupSecretParams,
justUploadedAvatars: GroupAvatarStateMap?,
) async throws -> GroupV2SnapshotResponse {
owsFail("Not implemented.")
}
public func updateGroupV2(
secretParams: GroupSecretParams,
isDeletingAccount: Bool,
changesBlock: (GroupsV2OutgoingChanges) -> Void,
) async throws -> [Promise<Void>] {
owsFail("Not implemented.")
}
public func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: DBWriteTransaction) {
owsFail("Not implemented.")
}
public func processProfileKeyUpdates() {
owsFail("Not implemented.")
}
public func updateLocalProfileKeyInGroup(groupId: Data, transaction: DBWriteTransaction) {
owsFail("Not implemented.")
}
public func updateGroupWithChangeActions(
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSecretParams: GroupSecretParams,
) async throws {
owsFail("Not implemented.")
}
public func uploadGroupAvatar(
avatarData: Data,
groupSecretParams: GroupSecretParams,
) async throws -> String {
owsFail("Not implemented.")
}
public func isGroupKnownToStorageService(
groupModel: TSGroupModelV2,
transaction: DBReadTransaction,
) -> Bool {
return true
}
public func groupRecordPendingStorageServiceRestore(masterKeyData: Data, transaction: DBReadTransaction) -> StorageServiceProtoGroupV2Record? {
return nil
}
public func restoreGroupFromStorageServiceIfNecessary(
groupRecord: StorageServiceProtoGroupV2Record,
account: AuthedAccount,
transaction: DBWriteTransaction,
) {
owsFail("Not implemented.")
}
public func cachedGroupInviteLinkPreview(groupSecretParams: GroupSecretParams) -> GroupInviteLinkPreview? {
owsFail("Not implemented.")
}
public func fetchGroupInviteLinkPreview(
inviteLinkPassword: Data?,
groupSecretParams: GroupSecretParams,
) async throws -> GroupInviteLinkPreview {
owsFail("Not implemented.")
}
public func fetchGroupInviteLinkPreviewAndRefreshGroup(
inviteLinkPassword: Data?,
groupSecretParams: GroupSecretParams,
) async throws -> GroupInviteLinkPreview {
owsFail("Not implemented.")
}
public func fetchGroupInviteLinkAvatar(
avatarUrlPath: String,
groupSecretParams: GroupSecretParams,
) async throws -> Data {
owsFail("Not implemented.")
}
public func fetchGroupAvatarRestoredFromBackup(
groupModel: TSGroupModelV2,
avatarUrlPath: String,
) async throws -> TSGroupModel.AvatarDataState {
owsFail("Not implemented")
}
public func downloadAndApplyGroupAvatarIfSkipped(_ secretParams: GroupSecretParams) async throws {
owsFail("Not implemented")
}
public func joinGroupViaInviteLink(
secretParams: GroupSecretParams,
inviteLinkPassword: Data,
downloadedAvatar: (avatarUrlPath: String, avatarData: Data?)?,
) async throws {
owsFail("Not implemented.")
}
public func cancelRequestToJoin(groupModel: TSGroupModelV2) async throws {
owsFail("Not implemented.")
}
public func fetchGroupExternalCredentials(secretParams: GroupSecretParams) async throws -> GroupsProtoGroupExternalCredential {
owsFail("Not implemented")
}
public func fetchSomeGroupChangeActions(secretParams: GroupSecretParams, source: GroupChangeActionFetchSource) async throws -> GroupChangesResponse {
owsFail("not implemented")
}
public func handleGroupSendEndorsementsResponse(
_ groupSendEndorsementsResponse: GroupSendEndorsementsResponse,
groupThreadId: Int64,
secretParams: GroupSecretParams,
membership: GroupMembership,
localAci: Aci,
tx: DBWriteTransaction,
) {
owsFail("Not implemented.")
}
}
// MARK: -
public class MockGroupV2Updates: GroupV2Updates {
public func fetchAndApplyCurrentGroupV2SnapshotFromService(
secretParams: LibSignalClient.GroupSecretParams,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
options: TSGroupModelOptions,
skipTerminatedGroup: Bool,
) async throws {
owsFail("Not implemented.")
}
public func autoRefreshGroup() async throws(CancellationError) {
owsFail("Not implemented.")
}
public func refreshGroupImpl(
secretParams: GroupSecretParams,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
source: GroupChangeActionFetchSource,
options: TSGroupModelOptions,
) async throws {
owsFail("Not implemented.")
}
public func updateGroupWithChangeActions(
groupId: GroupIdentifier,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
groupSendEndorsementsResponse: GroupSendEndorsementsResponse?,
downloadedAvatars: GroupAvatarStateMap,
transaction: DBWriteTransaction,
) throws -> TSGroupThread {
owsFail("Not implemented.")
}
}