Signal-iOS/SignalServiceKit/Groups/GroupsV2.swift
2024-03-27 12:22:52 -05:00

794 lines
26 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
public enum GroupsV2Error: Error {
/// By the time we tried to apply the change, it was irrelevant.
case redundantChange
/// The change we attempted conflicts with what is on the service.
case conflictingChangeOnService
case shouldRetry
case shouldDiscard
case timeout
case localUserNotInGroup
case cannotBuildGroupChangeProto_conflictingChange
case cannotBuildGroupChangeProto_lastAdminCantLeaveGroup
case cannotBuildGroupChangeProto_tooManyMembers
case gv2NotEnabled
case localUserIsAlreadyRequestingMember
case localUserIsNotARequestingMember
case requestingMemberCantLoadGroupState
case cantApplyChangesToPlaceholder
case expiredGroupInviteLink
case groupDoesNotExistOnService
case groupNeedsToBeMigrated
case groupCannotBeMigrated
case groupDowngradeNotAllowed
case missingGroupChangeProtos
case groupBlocked
case newMemberMissingAnnouncementOnlyCapability
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
}
// 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 generateGroupSecretParamsData() throws -> Data
func groupId(forGroupSecretParamsData groupSecretParamsData: Data) throws -> Data
func v2GroupId(forV1GroupId v1GroupId: Data) -> Data?
func hasProfileKeyCredential(
for address: SignalServiceAddress,
transaction: SDSAnyReadTransaction
) -> Bool
func masterKeyData(forGroupModel groupModel: TSGroupModelV2) throws -> Data
func buildGroupContextV2Proto(
groupModel: TSGroupModelV2,
changeActionsProtoData: Data?
) throws -> SSKProtoGroupContextV2
func groupV2ContextInfo(forMasterKeyData masterKeyData: Data?) throws -> GroupV2ContextInfo
func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: SDSAnyWriteTransaction)
func processProfileKeyUpdates()
func updateLocalProfileKeyInGroup(groupId: Data, transaction: SDSAnyWriteTransaction)
func isGroupKnownToStorageService(
groupModel: TSGroupModelV2,
transaction: SDSAnyReadTransaction
) -> Bool
func isValidGroupV2MasterKey(_ masterKeyData: Data) -> Bool
typealias ProfileKeyCredentialMap = [Aci: ExpiringProfileKeyCredential]
func createNewGroupOnService(
groupModel: TSGroupModelV2,
disappearingMessageToken: DisappearingMessageToken
) async throws
func loadProfileKeyCredentials(
for acis: [Aci],
forceRefresh: Bool
) async throws -> ProfileKeyCredentialMap
func tryToFetchProfileKeyCredentials(
for acis: [Aci],
ignoreMissingProfiles: Bool,
forceRefresh: Bool
) async throws
func fetchCurrentGroupV2Snapshot(groupModel: TSGroupModelV2) async throws -> GroupV2Snapshot
func fetchCurrentGroupV2Snapshot(groupSecretParamsData: Data) async throws -> GroupV2Snapshot
func updateGroupV2(
groupId: Data,
groupSecretParamsData: Data,
changesBlock: @escaping (GroupsV2OutgoingChanges) -> Void
) async throws -> TSGroupThread
func parseAndVerifyChangeActionsProto(
_ changeProtoData: Data,
ignoreSignature: Bool
) throws -> GroupsProtoGroupChangeActions
func updateGroupWithChangeActions(
groupId: Data,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
ignoreSignature: Bool,
groupSecretParamsData: Data
) async throws -> TSGroupThread
func uploadGroupAvatar(avatarData: Data, groupSecretParamsData: Data) async throws -> String
func groupInviteLink(forGroupModelV2 groupModelV2: TSGroupModelV2) throws -> URL
func parseGroupInviteLink(_ url: URL) -> GroupInviteLinkInfo?
func cachedGroupInviteLinkPreview(groupSecretParamsData: Data) -> GroupInviteLinkPreview?
// inviteLinkPassword is not necessary if we're already a member or have a pending request.
func fetchGroupInviteLinkPreview(
inviteLinkPassword: Data?,
groupSecretParamsData: Data,
allowCached: Bool
) async throws -> GroupInviteLinkPreview
func fetchGroupInviteLinkAvatar(
avatarUrlPath: String,
groupSecretParamsData: Data
) async throws -> Data
func joinGroupViaInviteLink(
groupId: Data,
groupSecretParamsData: Data,
inviteLinkPassword: Data,
groupInviteLinkPreview: GroupInviteLinkPreview,
avatarData: Data?
) async throws -> TSGroupThread
func cancelMemberRequests(groupModel: TSGroupModelV2) async throws -> TSGroupThread
func tryToUpdatePlaceholderGroupModelUsingInviteLinkPreview(
groupModel: TSGroupModelV2,
removeLocalUserBlock: @escaping (SDSAnyWriteTransaction) -> Void
) async throws
func fetchGroupExternalCredentials(groupModel: TSGroupModelV2) async throws -> GroupsProtoGroupExternalCredential
func groupRecordPendingStorageServiceRestore(
masterKeyData: Data,
transaction: SDSAnyReadTransaction
) -> StorageServiceProtoGroupV2Record?
func restoreGroupFromStorageServiceIfNecessary(
groupRecord: StorageServiceProtoGroupV2Record,
account: AuthedAccount,
transaction: SDSAnyWriteTransaction
)
}
// MARK: -
/// Represents a constructed group change, ready to be sent to the service.
public struct GroupsV2BuiltGroupChange {
/// 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.
public enum GroupUpdateMessageBehavior {
/// Send a group update message to all other group members.
case sendUpdateToOtherGroupMembers
/// Do not send any group update messages.
case sendNothing
}
public init(
proto: GroupsProtoGroupChangeActions,
groupUpdateMessageBehavior: GroupUpdateMessageBehavior
) {
self.proto = proto
self.groupUpdateMessageBehavior = groupUpdateMessageBehavior
}
public let proto: GroupsProtoGroupChangeActions
public let groupUpdateMessageBehavior: GroupUpdateMessageBehavior
}
public protocol GroupsV2OutgoingChanges: AnyObject {
var groupId: Data { get }
var groupSecretParamsData: Data { get }
var newAvatarData: Data? { get }
var newAvatarUrlPath: String? { get }
func setTitle(_ value: String)
func setDescriptionText(_ value: String?)
func setAvatar(_ avatar: (data: Data, urlPath: String)?)
func addMember(_ aci: Aci, role: TSGroupMemberRole)
func removeMember(_ serviceId: ServiceId)
func addBannedMember(_ aci: Aci)
func removeBannedMember(_ aci: Aci)
func revokeInvalidInvites()
func changeRoleForMember(_ aci: Aci, role: TSGroupMemberRole)
func setAccessForMembers(_ value: GroupV2Access)
func setAccessForAttributes(_ value: GroupV2Access)
func addInvitedMember(_ serviceId: ServiceId, role: TSGroupMemberRole)
func setLocalShouldAcceptInvite()
func setShouldLeaveGroupDeclineInvite()
func setNewDisappearingMessageToken(_ newDisappearingMessageToken: DisappearingMessageToken)
func setLinkMode(_ linkMode: GroupsV2LinkMode)
func rotateInviteLinkPassword()
func setIsAnnouncementsOnly(_ isAnnouncementsOnly: Bool)
func setShouldUpdateLocalProfileKey()
func buildGroupChangeProto(
currentGroupModel: TSGroupModelV2,
currentDisappearingMessageToken: DisappearingMessageToken,
forceRefreshProfileKeyCredentials: Bool
) -> Promise<GroupsV2BuiltGroupChange>
}
// MARK: -
public enum GroupUpdateMode {
// * Group update should halt at a specific revision.
// * Group update _should not_ block on message processing.
// * Group update _should not_ be throttled.
//
// upToRevision is inclusive.
case upToSpecificRevisionImmediately(upToRevision: UInt32)
// * Group update should continue until current revision.
// * Group update _should_ block on message processing.
// * Group update _should_ be throttled.
case upToCurrentRevisionAfterMessageProcessWithThrottling
// * Group update should continue until current revision.
// * Group update _should_ block on message processing.
// * Group update _should not_ be throttled.
case upToCurrentRevisionAfterMessageProcessWithoutThrottling
// * Group update should continue until current revision.
// * Group update _should not_ block on message processing.
// * Group update _should not_ be throttled.
case upToCurrentRevisionImmediately
public var shouldBlockOnMessageProcessing: Bool {
switch self {
case .upToCurrentRevisionAfterMessageProcessWithThrottling,
.upToCurrentRevisionAfterMessageProcessWithoutThrottling:
return true
default:
return false
}
}
public var shouldThrottle: Bool {
switch self {
case .upToCurrentRevisionAfterMessageProcessWithThrottling:
return true
default:
return false
}
}
public var upToRevision: UInt32? {
switch self {
case .upToSpecificRevisionImmediately(let upToRevision):
return upToRevision
default:
return nil
}
}
public var shouldUpdateToCurrentRevision: Bool {
switch self {
case .upToSpecificRevisionImmediately:
return false
default:
return true
}
}
}
// MARK: -
public protocol GroupV2Updates {
func tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithThrottling(_ groupThread: TSGroupThread)
func tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithoutThrottling(_ groupThread: TSGroupThread)
func tryToRefreshV2GroupUpToCurrentRevisionImmediately(groupId: Data,
groupSecretParamsData: Data) -> Promise<TSGroupThread>
func tryToRefreshV2GroupThread(groupId: Data,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
groupSecretParamsData: Data,
groupUpdateMode: GroupUpdateMode) -> Promise<TSGroupThread>
func updateGroupWithChangeActions(groupId: Data,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
downloadedAvatars: GroupV2DownloadedAvatars,
transaction: SDSAnyWriteTransaction) throws -> TSGroupThread
}
// MARK: -
public protocol GroupV2Snapshot {
var groupSecretParamsData: Data { get }
var debugDescription: String { get }
var revision: UInt32 { get }
var title: String { get }
var descriptionText: String? { get }
var avatarUrlPath: String? { get }
var avatarData: Data? { get }
var groupMembership: GroupMembership { get }
var groupAccess: GroupAccess { get }
var disappearingMessageToken: DisappearingMessageToken { get }
var profileKeys: [Aci: Data] { get }
var inviteLinkPassword: Data? { get }
var isAnnouncementsOnly: Bool { get }
}
// MARK: -
public struct GroupV2Change {
public var snapshot: GroupV2Snapshot?
public var changeActionsProto: GroupsProtoGroupChangeActions?
public let downloadedAvatars: GroupV2DownloadedAvatars
public init(snapshot: GroupV2Snapshot?,
changeActionsProto: GroupsProtoGroupChangeActions?,
downloadedAvatars: GroupV2DownloadedAvatars) {
owsAssert(snapshot != nil || changeActionsProto != nil)
self.snapshot = snapshot
self.changeActionsProto = changeActionsProto
self.downloadedAvatars = downloadedAvatars
}
public var revision: UInt32 {
return changeActionsProto?.revision ?? snapshot!.revision
}
}
// MARK: -
@objc
public class GroupV2ContextInfo: NSObject {
@objc
public let masterKeyData: Data
@objc
public let groupSecretParamsData: Data
@objc
public let groupId: Data
public init(masterKeyData: Data, groupSecretParamsData: Data, groupId: Data) {
self.masterKeyData = masterKeyData
self.groupSecretParamsData = groupSecretParamsData
self.groupId = groupId
}
}
// MARK: -
@objc
public class GroupInviteLinkInfo: NSObject {
@objc
public let masterKey: Data
@objc
public let inviteLinkPassword: Data
public init(masterKey: Data, inviteLinkPassword: Data) {
self.masterKey = masterKey
self.inviteLinkPassword = inviteLinkPassword
}
}
// MARK: -
@objc
public class GroupInviteLinkPreview: NSObject {
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
public init(title: String,
descriptionText: String?,
avatarUrlPath: String?,
memberCount: UInt32,
addFromInviteLinkAccess: GroupV2Access,
revision: UInt32,
isLocalUserRequestingMember: Bool) {
self.title = title
self.descriptionText = descriptionText
self.avatarUrlPath = avatarUrlPath
self.memberCount = memberCount
self.addFromInviteLinkAccess = addFromInviteLinkAccess
self.revision = revision
self.isLocalUserRequestingMember = isLocalUserRequestingMember
}
@objc
public override func isEqual(_ object: Any?) -> Bool {
guard let otherRecipient = object as? GroupInviteLinkPreview else { return false }
return (title == otherRecipient.title &&
descriptionText == otherRecipient.descriptionText &&
avatarUrlPath == otherRecipient.avatarUrlPath &&
memberCount == otherRecipient.memberCount &&
addFromInviteLinkAccess == otherRecipient.addFromInviteLinkAccess &&
revision == otherRecipient.revision &&
isLocalUserRequestingMember == otherRecipient.isLocalUserRequestingMember)
}
}
// MARK: -
public struct GroupV2DownloadedAvatars {
// A map of avatar url-to-avatar data.
private var avatarMap = [String: Data]()
public init() {}
public mutating func set(avatarData: Data, avatarUrlPath: String) {
avatarMap[avatarUrlPath] = avatarData
}
public mutating func merge(_ other: GroupV2DownloadedAvatars) {
for (avatarUrlPath, avatarData) in other.avatarMap {
avatarMap[avatarUrlPath] = avatarData
}
}
public func hasAvatarData(for avatarUrlPath: String) -> Bool {
return avatarMap[avatarUrlPath] != nil
}
public func avatarData(for avatarUrlPath: String) throws -> Data {
guard let avatarData = avatarMap[avatarUrlPath] else {
throw OWSAssertionError("Missing avatarData.")
}
return avatarData
}
public var avatarUrlPaths: [String] {
return Array(avatarMap.keys)
}
public static var empty: GroupV2DownloadedAvatars {
return GroupV2DownloadedAvatars()
}
public static func from(groupModel: TSGroupModelV2) -> GroupV2DownloadedAvatars {
return from(avatarData: groupModel.avatarData, avatarUrlPath: groupModel.avatarUrlPath)
}
public static func from(changes: GroupsV2OutgoingChanges) -> GroupV2DownloadedAvatars {
return from(avatarData: changes.newAvatarData, avatarUrlPath: changes.newAvatarUrlPath)
}
private static func from(avatarData: Data?, avatarUrlPath: String?) -> GroupV2DownloadedAvatars {
let hasAvatarData = avatarData != nil
let hasAvatarUrlPath = avatarUrlPath != nil
guard hasAvatarData == hasAvatarUrlPath else {
// Fail but continue in production; we can recover from this scenario.
owsFailDebug("hasAvatarData: \(hasAvatarData) != hasAvatarUrlPath: \(hasAvatarUrlPath)")
return .empty
}
guard let avatarData = avatarData,
let avatarUrlPath = avatarUrlPath else {
// No avatar.
return .empty
}
guard TSGroupModel.isValidGroupAvatarData(avatarData) else {
owsFailDebug("Invalid group avatar")
return .empty
}
// Avatar found, add it to the result set.
var downloadedAvatars = GroupV2DownloadedAvatars()
downloadedAvatars.set(avatarData: avatarData, 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(
groupModel: TSGroupModelV2,
disappearingMessageToken: DisappearingMessageToken
) async throws {
owsFail("Not implemented.")
}
public func generateGroupSecretParamsData() throws -> Data {
if CurrentAppContext().isRunningTests {
return Randomness.generateRandomBytes(289)
}
owsFail("Not implemented.")
}
public func groupId(forGroupSecretParamsData groupSecretParamsData: Data) throws -> Data {
if CurrentAppContext().isRunningTests {
owsAssert(groupSecretParamsData.count >= 32)
return groupSecretParamsData.subdata(in: Int(0)..<Int(32))
}
owsFail("Not implemented.")
}
public func v2GroupId(forV1GroupId v1GroupId: Data) -> Data? {
let v2GroupId = v1GroupId + v1GroupId
owsAssert(GroupManager.isV1GroupId(v1GroupId))
owsAssert(GroupManager.isV2GroupId(v2GroupId))
return v2GroupId
}
public func hasProfileKeyCredential(for address: SignalServiceAddress,
transaction: SDSAnyReadTransaction) -> Bool {
owsFail("Not implemented.")
}
public func loadProfileKeyCredentials(
for acis: [Aci],
forceRefresh: Bool
) async throws -> ProfileKeyCredentialMap {
owsFail("Not implemented.")
}
public func tryToFetchProfileKeyCredentials(
for acis: [Aci],
ignoreMissingProfiles: Bool,
forceRefresh: Bool
) async throws { }
public func fetchCurrentGroupV2Snapshot(groupModel: TSGroupModelV2) async throws -> GroupV2Snapshot {
owsFail("Not implemented.")
}
public func fetchCurrentGroupV2Snapshot(groupSecretParamsData: Data) async throws -> GroupV2Snapshot {
owsFail("Not implemented.")
}
public func masterKeyData(forGroupModel groupModel: TSGroupModelV2) throws -> Data {
owsFail("Not implemented.")
}
public func buildGroupContextV2Proto(groupModel: TSGroupModelV2,
changeActionsProtoData: Data?) throws -> SSKProtoGroupContextV2 {
owsFail("Not implemented.")
}
public func updateGroupV2(
groupId: Data,
groupSecretParamsData: Data,
changesBlock: @escaping (GroupsV2OutgoingChanges) -> Void
) async throws -> TSGroupThread {
owsFail("Not implemented.")
}
public var groupV2ContextInfos = [Data: GroupV2ContextInfo]()
public func groupV2ContextInfo(forMasterKeyData masterKeyData: Data?) throws -> GroupV2ContextInfo {
guard let masterKeyData = masterKeyData, let info = groupV2ContextInfos[masterKeyData] else {
owsFail("No registered GroupV2ContextInfo on mock")
}
return info
}
public func parseAndVerifyChangeActionsProto(
_ changeProtoData: Data,
ignoreSignature: Bool
) throws -> GroupsProtoGroupChangeActions {
owsFail("Not implemented.")
}
public func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: SDSAnyWriteTransaction) {
owsFail("Not implemented.")
}
public func processProfileKeyUpdates() {
owsFail("Not implemented.")
}
public func updateLocalProfileKeyInGroup(groupId: Data, transaction: SDSAnyWriteTransaction) {
owsFail("Not implemented.")
}
public func updateGroupWithChangeActions(
groupId: Data,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
ignoreSignature: Bool,
groupSecretParamsData: Data
) async throws -> TSGroupThread {
owsFail("Not implemented.")
}
public func uploadGroupAvatar(
avatarData: Data,
groupSecretParamsData: Data
) async throws -> String {
owsFail("Not implemented.")
}
public func isGroupKnownToStorageService(
groupModel: TSGroupModelV2,
transaction: SDSAnyReadTransaction
) -> Bool {
return true
}
public func groupRecordPendingStorageServiceRestore(masterKeyData: Data, transaction: SDSAnyReadTransaction) -> StorageServiceProtoGroupV2Record? {
return nil
}
public func restoreGroupFromStorageServiceIfNecessary(
groupRecord: StorageServiceProtoGroupV2Record,
account: AuthedAccount,
transaction: SDSAnyWriteTransaction
) {
owsFail("Not implemented.")
}
public func isValidGroupV2MasterKey(_ masterKeyData: Data) -> Bool {
owsFail("Not implemented.")
}
public func groupInviteLink(forGroupModelV2 groupModelV2: TSGroupModelV2) throws -> URL {
owsFail("Not implemented.")
}
public func parseGroupInviteLink(_ url: URL) -> GroupInviteLinkInfo? {
if CurrentAppContext().isRunningTests {
Logger.warn("Not implemented.")
} else {
owsFail("Not implemented.")
}
return nil
}
public func cachedGroupInviteLinkPreview(groupSecretParamsData: Data) -> GroupInviteLinkPreview? {
owsFail("Not implemented.")
}
public func fetchGroupInviteLinkPreview(
inviteLinkPassword: Data?,
groupSecretParamsData: Data,
allowCached: Bool
) async throws -> GroupInviteLinkPreview {
owsFail("Not implemented.")
}
public func fetchGroupInviteLinkAvatar(
avatarUrlPath: String,
groupSecretParamsData: Data
) async throws -> Data {
owsFail("Not implemented.")
}
public func joinGroupViaInviteLink(
groupId: Data,
groupSecretParamsData: Data,
inviteLinkPassword: Data,
groupInviteLinkPreview: GroupInviteLinkPreview,
avatarData: Data?
) async throws -> TSGroupThread {
owsFail("Not implemented.")
}
public func cancelMemberRequests(groupModel: TSGroupModelV2) async throws -> TSGroupThread {
owsFail("Not implemented.")
}
public func tryToUpdatePlaceholderGroupModelUsingInviteLinkPreview(
groupModel _: TSGroupModelV2,
removeLocalUserBlock _: (SDSAnyWriteTransaction) -> Void
) {
owsFail("Not implemented.")
}
public func fetchGroupExternalCredentials(groupModel: TSGroupModelV2) async throws -> GroupsProtoGroupExternalCredential {
owsFail("Not implemented")
}
}
// MARK: -
public class MockGroupV2Updates: GroupV2Updates {
public func tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithThrottling(_ groupThread: TSGroupThread) {
owsFail("Not implemented.")
}
public func tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithoutThrottling(_ groupThread: TSGroupThread) {
owsFail("Not implemented.")
}
public func tryToRefreshV2GroupUpToCurrentRevisionImmediately(groupId: Data,
groupSecretParamsData: Data) -> Promise<TSGroupThread> {
owsFail("Not implemented.")
}
public func tryToRefreshV2GroupThread(groupId: Data,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
groupSecretParamsData: Data,
groupUpdateMode: GroupUpdateMode) -> Promise<TSGroupThread> {
owsFail("Not implemented.")
}
public func updateGroupWithChangeActions(groupId: Data,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
changeActionsProto: GroupsProtoGroupChangeActions,
downloadedAvatars: GroupV2DownloadedAvatars,
transaction: SDSAnyWriteTransaction) throws -> TSGroupThread {
owsFail("Not implemented.")
}
}