Signal-iOS/SignalServiceKit/src/groups/GroupManager.swift
2020-07-09 08:54:24 -03:00

1960 lines
96 KiB
Swift

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
import Foundation
import PromiseKit
@objc
public class UpsertGroupResult: NSObject {
@objc
public enum Action: UInt {
case inserted
case updatedWithUserFacingChanges
case updatedWithoutUserFacingChanges
case unchanged
}
@objc
public let action: Action
@objc
public let groupThread: TSGroupThread
public required init(action: Action, groupThread: TSGroupThread) {
self.action = action
self.groupThread = groupThread
}
}
// MARK: -
// * The "local" methods are used in response to the local user's interactions.
// * The "remote" methods are used in response to remote activity (incoming messages,
// sync transcripts, group syncs, etc.).
@objc
public class GroupManager: NSObject {
// MARK: - Dependencies
private class var tsAccountManager: TSAccountManager {
return TSAccountManager.sharedInstance()
}
private class var databaseStorage: SDSDatabaseStorage {
return SDSDatabaseStorage.shared
}
private class var messageSenderJobQueue: MessageSenderJobQueue {
return SSKEnvironment.shared.messageSenderJobQueue
}
private class var messageSender: MessageSender {
return SSKEnvironment.shared.messageSender
}
private class var groupsV2: GroupsV2Swift {
return SSKEnvironment.shared.groupsV2 as! GroupsV2Swift
}
private class var contactsManager: ContactsManagerProtocol {
return SSKEnvironment.shared.contactsManager
}
private class var profileManager: ProfileManagerProtocol {
return SSKEnvironment.shared.profileManager
}
private class var storageServiceManager: StorageServiceManagerProtocol {
return SSKEnvironment.shared.storageServiceManager
}
fileprivate class var messageProcessing: MessageProcessing {
return SSKEnvironment.shared.messageProcessing
}
private class var bulkProfileFetch: BulkProfileFetch {
return SSKEnvironment.shared.bulkProfileFetch
}
private class var bulkUUIDLookup: BulkUUIDLookup {
return SSKEnvironment.shared.bulkUUIDLookup
}
private class var contactsUpdater: ContactsUpdater {
return SSKEnvironment.shared.contactsUpdater
}
// MARK: -
// Never instantiate this class.
private override init() {}
// MARK: -
// GroupsV2 TODO: Finalize this value with the designers.
public static let groupUpdateTimeoutDuration: TimeInterval = 30
public static var maxGroupMemberCount: UInt {
guard AppReadiness.isAppReady else {
owsFailDebug("Storage is not yet ready.")
return RemoteConfig.groupsV2maxMemberCountDefault
}
return RemoteConfig.groupsV2maxMemberCount
}
public static let maxGroupNameLength: Int = 32
// GroupsV2 TODO: Finalize this value with Ehren.
public static let changeProtoEpoch: UInt32 = 0
// This matches kOversizeTextMessageSizeThreshold.
public static let maxEmbeddedChangeProtoLength: UInt = 2 * 1024
private static func groupIdLength(for groupsVersion: GroupsVersion) -> Int32 {
switch groupsVersion {
case .V1:
return kGroupIdLengthV1
case .V2:
return kGroupIdLengthV2
}
}
@objc
public static func isValidGroupId(_ groupId: Data, groupsVersion: GroupsVersion) -> Bool {
let expectedLength = groupIdLength(for: groupsVersion)
guard groupId.count == expectedLength else {
owsFailDebug("Invalid groupId: \(groupId.count) != \(expectedLength)")
return false
}
return true
}
@objc
public static func isValidGroupIdOfAnyKind(_ groupId: Data) -> Bool {
guard groupId.count == kGroupIdLengthV1 ||
groupId.count == kGroupIdLengthV2 else {
owsFailDebug("Invalid groupId: \(groupId.count) != \(kGroupIdLengthV1), \(kGroupIdLengthV2)")
return false
}
return true
}
// MARK: - Group Models
// This should only be used for certain legacy edge cases.
@objc
public static func fakeGroupModel(groupId: Data?,
transaction: SDSAnyReadTransaction) -> TSGroupModel? {
do {
var builder = TSGroupModelBuilder()
builder.groupId = groupId
builder.groupsVersion = .V1
return try builder.build(transaction: transaction)
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
public static func canUseV2(for members: Set<SignalServiceAddress>,
transaction: SDSAnyReadTransaction) -> Bool {
for recipientAddress in members {
guard doesUserSupportGroupsV2(address: recipientAddress, transaction: transaction) else {
Logger.warn("Creating legacy group; member missing UUID or Groups v2 capability.")
return false
}
// GroupsV2 TODO: We should finalize the exact decision-making process here.
// Should having a profile key credential figure in? At least for a while?
}
return true
}
public static func doesUserSupportGroupsV2(address: SignalServiceAddress,
transaction: SDSAnyReadTransaction) -> Bool {
guard address.isValid else {
Logger.warn("Invalid address: \(address).")
return false
}
guard address.uuid != nil else {
Logger.warn("Member without UUID.")
return false
}
guard doesUserHaveGroupsV2Capability(address: address,
transaction: transaction) else {
Logger.warn("Member without Groups v2 capability.")
return false
}
// NOTE: We do consider users to support groups v2 even if:
//
// * We don't know their profile key.
// * They've never done a versioned profile update.
// * We don't have a profile key credential for them.
return true
}
@objc
public static var defaultGroupsVersion: GroupsVersion {
guard RemoteConfig.groupsV2CreateGroups else {
return .V1
}
return .V2
}
// MARK: - Create New Group
//
// "New" groups are being created for the first time; they might need to be created on the service.
// NOTE: groupId param should only be set for tests.
public static func localCreateNewGroup(members: [SignalServiceAddress],
groupId: Data? = nil,
name: String? = nil,
avatarImage: UIImage?,
newGroupSeed: NewGroupSeed? = nil,
shouldSendMessage: Bool) -> Promise<TSGroupThread> {
return DispatchQueue.global().async(.promise) {
return TSGroupModel.data(forGroupAvatar: avatarImage)
}.then(on: .global()) { avatarData in
return localCreateNewGroup(members: members,
groupId: groupId,
name: name,
avatarData: avatarData,
newGroupSeed: newGroupSeed,
shouldSendMessage: shouldSendMessage)
}
}
// NOTE: groupId param should only be set for tests.
public static func localCreateNewGroup(members membersParam: [SignalServiceAddress],
groupId: Data? = nil,
name: String? = nil,
avatarData: Data? = nil,
newGroupSeed: NewGroupSeed? = nil,
shouldSendMessage: Bool) -> Promise<TSGroupThread> {
guard let localAddress = tsAccountManager.localAddress else {
return Promise(error: OWSAssertionError("Missing localAddress."))
}
// By default, DMs are disable for new groups.
let disappearingMessageToken = DisappearingMessageToken.disabledToken
return firstly { () -> Promise<Void> in
return self.ensureLocalProfileHasCommitmentIfNecessary()
}.then(on: .global()) { () -> Promise<Void> in
var memberSet = Set(membersParam)
memberSet.insert(localAddress)
return self.tryToEnableGroupsV2(for: Array(memberSet), isBlocking: true, ignoreErrors: true)
}.map(on: .global()) { () throws -> GroupMembership in
// Build member list.
//
// The group creator is an administrator;
// the other members are normal users.
var builder = GroupMembership.Builder()
builder.addNonPendingMembers(Set(membersParam), role: .normal)
builder.remove(localAddress)
builder.addNonPendingMember(localAddress, role: .administrator)
return builder.build()
}.then(on: .global()) { (groupMembership: GroupMembership) -> Promise<GroupMembership> in
// If we might create a v2 group,
// try to obtain profile key credentials for all group members
// including ourself, unless we already have them on hand.
guard RemoteConfig.groupsV2CreateGroups else {
return Promise.value(groupMembership)
}
return firstly { () -> Promise<Void> in
self.groupsV2.tryToEnsureProfileKeyCredentials(for: Array(groupMembership.allUsers))
}.map(on: .global()) { (_) -> GroupMembership in
return groupMembership
}
}.map(on: .global()) { (proposedGroupMembership: GroupMembership) throws -> TSGroupModel in
let groupAccess = GroupAccess.defaultForV2
let groupModel = try self.databaseStorage.read { (transaction) throws -> TSGroupModel in
// Before we create a v2 group, we need to separate out the
// pending and non-pending members. If we already know we're
// going to create a v1 group, we shouldn't separate them.
let groupMembership = self.separatePendingMembers(in: proposedGroupMembership,
oldGroupModel: nil,
transaction: transaction)
guard groupMembership.nonPendingMembers.contains(localAddress) else {
throw OWSAssertionError("Missing localAddress.")
}
// Build the "initial" group model.
// This will finalize the immutable aspects of the group, e.g.:
//
// * Is it v1 or v2?
// * If it is v2, what is the group secret.
//
// This might not be the "final" model - the
// avatar url path might be filled in below.
var builder = TSGroupModelBuilder()
builder.groupId = groupId
builder.name = name
builder.avatarData = avatarData
builder.avatarUrlPath = nil
builder.groupMembership = groupMembership
builder.groupAccess = groupAccess
builder.newGroupSeed = newGroupSeed
return try builder.build(transaction: transaction)
}
return groupModel
}.then(on: DispatchQueue.global()) { (proposedGroupModel: TSGroupModel) -> Promise<TSGroupModel> in
guard let proposedGroupModelV2 = proposedGroupModel as? TSGroupModelV2 else {
// We don't need to upload avatars for v1 groups.
return Promise.value(proposedGroupModel)
}
guard let avatarData = avatarData else {
// No avatar to upload.
return Promise.value(proposedGroupModel)
}
// Upload avatar.
return firstly {
self.groupsV2.uploadGroupAvatar(avatarData: avatarData,
groupSecretParamsData: proposedGroupModelV2.secretParamsData)
}.map(on: DispatchQueue.global()) { (avatarUrlPath: String) -> TSGroupModel in
// Fill in the avatarUrl on the group model.
return try self.databaseStorage.read { transaction in
var builder = proposedGroupModel.asBuilder
builder.avatarUrlPath = avatarUrlPath
return try builder.build(transaction: transaction)
}
}
}.then(on: .global()) { (proposedGroupModel: TSGroupModel) -> Promise<TSGroupModel> in
guard let proposedGroupModelV2 = proposedGroupModel as? TSGroupModelV2 else {
// v1 groups don't need to be created on the service.
return Promise.value(proposedGroupModel)
}
return firstly {
self.groupsV2.createNewGroupOnService(groupModel: proposedGroupModelV2)
}.then(on: .global()) { _ in
self.groupsV2.fetchCurrentGroupV2Snapshot(groupModel: proposedGroupModelV2)
}.map(on: .global()) { (groupV2Snapshot: GroupV2Snapshot) throws -> TSGroupModel in
let createdGroupModel = try self.databaseStorage.read { transaction in
return try TSGroupModelBuilder(groupV2Snapshot: groupV2Snapshot).build(transaction: transaction)
}
if proposedGroupModel != createdGroupModel {
Logger.verbose("proposedGroupModel: \(proposedGroupModel.debugDescription)")
Logger.verbose("createdGroupModel: \(createdGroupModel.debugDescription)")
owsFailDebug("Proposed group model does not match created group model.")
}
return createdGroupModel
}
}.then(on: .global()) { (groupModel: TSGroupModel) -> Promise<TSGroupThread> in
// We're creating this thread, we added ourselves
if groupModel.groupsVersion == .V1 {
groupModel.addedByAddress = self.tsAccountManager.localAddress
}
let thread = databaseStorage.write { (transaction: SDSAnyWriteTransaction) -> TSGroupThread in
return self.insertGroupThreadInDatabaseAndCreateInfoMessage(groupModel: groupModel,
disappearingMessageToken: disappearingMessageToken,
groupUpdateSourceAddress: localAddress,
transaction: transaction)
}
self.profileManager.addThread(toProfileWhitelist: thread)
if shouldSendMessage {
return firstly {
sendDurableNewGroupMessage(forThread: thread)
}.map(on: .global()) { _ in
return thread
}
} else {
return Promise.value(thread)
}
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration,
description: "Create new group") {
GroupsV2Error.timeout
}
}
// Separates pending and non-pending members.
// We cannot add non-pending members unless:
//
// * We know their UUID.
// * We know their profile key.
// * We have a profile key credential for them.
// * Their account has the "groups v2" capability
// (e.g. all of their clients support groups v2.
private static func separatePendingMembers(in newGroupMembership: GroupMembership,
oldGroupModel: TSGroupModel?,
transaction: SDSAnyReadTransaction) -> GroupMembership {
guard let localUuid = tsAccountManager.localUuid else {
owsFailDebug("Missing localUuid.")
return newGroupMembership
}
let localAddress = SignalServiceAddress(uuid: localUuid)
let newMembers: Set<SignalServiceAddress>
var builder = GroupMembership.Builder()
if let oldGroupModel = oldGroupModel {
// Updating existing group
let oldGroupMembership = oldGroupModel.groupMembership
assert(oldGroupModel.groupsVersion == .V2)
newMembers = newGroupMembership.allUsers.subtracting(oldGroupMembership.allUsers)
// Carry over existing members as they stand.
let existingMembers = oldGroupMembership.allUsers.intersection(newGroupMembership.allUsers)
for address in existingMembers {
builder.copyMember(address, from: oldGroupMembership)
}
} else {
// Creating new group
// First, skip separation when creating v1 groups.
guard canUseV2(for: newGroupMembership.allUsers, transaction: transaction) else {
// If any member of a new group doesn't support groups v2,
// we're going to create a v1 group. In that case, we
// don't want to separate out pending members.
return newGroupMembership
}
newMembers = newGroupMembership.allUsers
}
// We only need to separate new members.
for address in newMembers {
guard doesUserSupportGroupsV2(address: address, transaction: transaction) else {
// Members of v2 groups must support groups v2.
// This should never happen. We prevent this
// when creating new groups above by checking
// canUseV2(...). We will prevent this when
// updating existing groups in the UI.
//
// GroupsV2 TODO: This should throw after we require
// all new groups to be v2 groups.
owsFailDebug("Invalid address: \(address)")
continue
}
// We must call this _after_ we try to fetch profile key credentials for
// all members.
let isPending = !groupsV2.hasProfileKeyCredential(for: address,
transaction: transaction)
guard let role = newGroupMembership.role(for: address) else {
owsFailDebug("Missing role: \(address)")
continue
}
// If groupsV2forceInvites is set, we invite other members
// instead of adding them.
if address != localAddress &&
DebugFlags.groupsV2forceInvites {
builder.addPendingMember(address, role: role, addedByUuid: localUuid)
} else if isPending {
builder.addPendingMember(address, role: role, addedByUuid: localUuid)
} else {
builder.addNonPendingMember(address, role: role)
}
}
return builder.build()
}
// success and failure are invoked on the main thread.
@objc
public static func localCreateNewGroupObjc(members: [SignalServiceAddress],
groupId: Data?,
name: String,
avatarImage: UIImage?,
newGroupSeed: NewGroupSeed?,
shouldSendMessage: Bool,
success: @escaping (TSGroupThread) -> Void,
failure: @escaping (Error) -> Void) {
firstly {
self.localCreateNewGroup(members: members,
groupId: groupId,
name: name,
avatarImage: avatarImage,
newGroupSeed: newGroupSeed,
shouldSendMessage: shouldSendMessage)
}.done { thread in
success(thread)
}.catch { error in
failure(error)
}
}
// success and failure are invoked on the main thread.
@objc
public static func localCreateNewGroupObjc(members: [SignalServiceAddress],
groupId: Data?,
name: String,
avatarData: Data?,
newGroupSeed: NewGroupSeed?,
shouldSendMessage: Bool,
success: @escaping (TSGroupThread) -> Void,
failure: @escaping (Error) -> Void) {
firstly {
localCreateNewGroup(members: members,
groupId: groupId,
name: name,
avatarData: avatarData,
newGroupSeed: newGroupSeed,
shouldSendMessage: shouldSendMessage)
}.done { thread in
success(thread)
}.catch { error in
failure(error)
}
}
// MARK: - Tests
#if TESTABLE_BUILD
@objc
public static func createGroupForTests(members: [SignalServiceAddress],
name: String? = nil,
avatarData: Data? = nil) throws -> TSGroupThread {
return try databaseStorage.write { transaction in
return try createGroupForTests(members: members,
name: name,
avatarData: avatarData,
transaction: transaction)
}
}
#if TESTABLE_BUILD
@objc
public static let shouldForceV1Groups = AtomicBool(false)
@objc
public class func forceV1Groups() {
shouldForceV1Groups.set(true)
}
#endif
@objc
public static func createGroupForTestsObjc(members: [SignalServiceAddress],
name: String? = nil,
avatarData: Data? = nil,
transaction: SDSAnyWriteTransaction) -> TSGroupThread {
do {
#if TESTABLE_BUILD
let groupsVersion = (shouldForceV1Groups.get()
? .V1
: self.defaultGroupsVersion)
#else
let groupsVersion = self.defaultGroupsVersion
#endif
return try createGroupForTests(members: members,
name: name,
avatarData: avatarData,
groupsVersion: groupsVersion,
transaction: transaction)
} catch {
owsFail("Error: \(error)")
}
}
public static func createGroupForTests(members: [SignalServiceAddress],
name: String? = nil,
avatarData: Data? = nil,
groupId: Data? = nil,
groupsVersion: GroupsVersion? = nil,
transaction: SDSAnyWriteTransaction) throws -> TSGroupThread {
guard let localAddress = tsAccountManager.localAddress else {
throw OWSAssertionError("Missing localAddress.")
}
// GroupsV2 TODO: Elaborate tests to include admins, pending members, etc.
let groupMembership = GroupMembership(v1Members: Set(members))
// GroupsV2 TODO: Let tests specify access levels.
// GroupsV2 TODO: Fill in avatarUrlPath when we test v2 groups.
let groupAccess = GroupAccess.allAccess
// Use buildGroupModel() to fill in defaults, like it was a new group.
var builder = TSGroupModelBuilder()
builder.groupId = groupId
builder.name = name
builder.avatarData = avatarData
builder.avatarUrlPath = nil
builder.groupMembership = groupMembership
builder.groupAccess = groupAccess
builder.groupsVersion = groupsVersion
let groupModel = try builder.build(transaction: transaction)
// Just create it in the database, don't create it on the service.
return try remoteUpsertExistingGroup(groupModel: groupModel,
disappearingMessageToken: nil,
groupUpdateSourceAddress: localAddress,
transaction: transaction).groupThread
}
#endif
// MARK: - Upsert Existing Group
//
// "Existing" groups have already been created, we just need to make sure they're in the database.
//
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
@objc
public static func remoteUpsertExistingGroupV1(groupId: Data,
name: String? = nil,
avatarData: Data? = nil,
members: [SignalServiceAddress],
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSourceAddress: SignalServiceAddress?,
infoMessagePolicy: InfoMessagePolicy = .always,
transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult {
guard isValidGroupId(groupId, groupsVersion: .V1) else {
throw OWSAssertionError("Invalid group id.")
}
let groupMembership = GroupMembership(v1Members: Set(members))
var builder = TSGroupModelBuilder()
builder.groupId = groupId
builder.name = name
builder.avatarData = avatarData
builder.avatarUrlPath = nil
builder.groupMembership = groupMembership
builder.groupsVersion = .V1
let groupModel = try builder.build(transaction: transaction)
return try remoteUpsertExistingGroup(groupModel: groupModel,
disappearingMessageToken: disappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
infoMessagePolicy: infoMessagePolicy,
transaction: transaction)
}
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
public static func remoteUpsertExistingGroup(groupModel: TSGroupModel,
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSourceAddress: SignalServiceAddress?,
infoMessagePolicy: InfoMessagePolicy = .always,
transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult {
return try self.tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: groupModel,
newDisappearingMessageToken: disappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
canInsert: true,
infoMessagePolicy: infoMessagePolicy,
transaction: transaction)
}
// MARK: - Update Existing Group (Remote)
// Unlike remoteUpsertExistingGroupV1(), this method never inserts.
//
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
@objc
public static func remoteUpdateToExistingGroupV1(groupModel proposedGroupModel: TSGroupModel,
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSourceAddress: SignalServiceAddress?,
transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult {
let groupId = proposedGroupModel.groupId
let updateInfo: UpdateInfo
do {
updateInfo = try updateInfoV1(groupModel: proposedGroupModel,
dmConfiguration: nil,
transaction: transaction)
} catch GroupsV2Error.redundantChange {
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Missing groupThread.")
}
return UpsertGroupResult(action: .unchanged, groupThread: groupThread)
}
let newGroupModel = updateInfo.newGroupModel
return try self.tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: newGroupModel,
newDisappearingMessageToken: disappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
canInsert: false,
transaction: transaction)
}
@objc
public static func remoteUpdateAvatarToExistingGroupV1(groupModel oldGroupModel: TSGroupModel,
avatarData: Data?,
groupUpdateSourceAddress: SignalServiceAddress?,
transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult {
var builder = oldGroupModel.asBuilder
builder.avatarData = avatarData
builder.avatarUrlPath = nil
let newGroupModel = try builder.build(transaction: transaction)
return try remoteUpdateToExistingGroupV1(groupModel: newGroupModel,
disappearingMessageToken: nil,
groupUpdateSourceAddress: groupUpdateSourceAddress,
transaction: transaction)
}
// MARK: - Update Existing Group
private struct UpdateInfo {
let groupId: Data
let newGroupModel: TSGroupModel
let oldDMConfiguration: OWSDisappearingMessagesConfiguration
let newDMConfiguration: OWSDisappearingMessagesConfiguration
}
// If dmConfiguration is nil, don't change the disappearing messages configuration.
public static func localUpdateExistingGroup(oldGroupModel: TSGroupModel?,
newGroupModel: TSGroupModel,
dmConfiguration: OWSDisappearingMessagesConfiguration?,
groupUpdateSourceAddress: SignalServiceAddress?) -> Promise<TSGroupThread> {
if let newGroupModel = newGroupModel as? TSGroupModelV2 {
return localUpdateExistingGroupV2(oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
dmConfiguration: dmConfiguration,
groupUpdateSourceAddress: groupUpdateSourceAddress)
} else {
return localUpdateExistingGroupV1(groupModel: newGroupModel,
dmConfiguration: dmConfiguration,
groupUpdateSourceAddress: groupUpdateSourceAddress)
}
}
// If dmConfiguration is nil, don't change the disappearing messages configuration.
private static func localUpdateExistingGroupV1(groupModel proposedGroupModel: TSGroupModel,
dmConfiguration: OWSDisappearingMessagesConfiguration?,
groupUpdateSourceAddress: SignalServiceAddress?) -> Promise<TSGroupThread> {
return self.databaseStorage.write(.promise) { (transaction) throws -> UpsertGroupResult in
let updateInfo = try self.updateInfoV1(groupModel: proposedGroupModel,
dmConfiguration: dmConfiguration,
transaction: transaction)
let newGroupModel = updateInfo.newGroupModel
let upsertGroupResult = try self.tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: newGroupModel,
newDisappearingMessageToken: dmConfiguration?.asToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
canInsert: false,
transaction: transaction)
if let dmConfiguration = dmConfiguration {
let groupThread = upsertGroupResult.groupThread
let updateResult = self.updateDisappearingMessagesInDatabaseAndCreateMessages(token: dmConfiguration.asToken,
thread: groupThread,
shouldInsertInfoMessage: true,
groupUpdateSourceAddress: groupUpdateSourceAddress,
transaction: transaction)
self.sendDisappearingMessagesConfigurationMessage(updateResult: updateResult,
thread: groupThread,
transaction: transaction)
}
return upsertGroupResult
}.then(on: .global()) { (upsertGroupResult: UpsertGroupResult) throws -> Promise<TSGroupThread> in
let groupThread = upsertGroupResult.groupThread
guard upsertGroupResult.action != .unchanged else {
// Don't bother sending a message if the update was redundant.
return Promise.value(groupThread)
}
return self.sendGroupUpdateMessage(thread: groupThread)
.map(on: .global()) { _ in
return groupThread
}
}
}
// If dmConfiguration is nil, don't change the disappearing messages configuration.
private static func localUpdateExistingGroupV2(oldGroupModel: TSGroupModel?,
newGroupModel proposedGroupModel: TSGroupModelV2,
dmConfiguration: OWSDisappearingMessagesConfiguration?,
groupUpdateSourceAddress: SignalServiceAddress?) -> Promise<TSGroupThread> {
guard let oldGroupModel = oldGroupModel as? TSGroupModelV2 else {
return Promise(error: OWSAssertionError("Invalid group model."))
}
return firstly { () -> Promise<Void> in
guard RemoteConfig.groupsV2GoodCitizen else {
return Promise.value(())
}
return self.tryToEnableGroupsV2(for: Array(proposedGroupModel.groupMembership.allUsers), isBlocking: true, ignoreErrors: true)
}.then(on: .global()) { () -> Promise<Void> in
return self.ensureLocalProfileHasCommitmentIfNecessary()
}.then(on: DispatchQueue.global()) { () -> Promise<String?> in
guard let avatarData = proposedGroupModel.groupAvatarData else {
// No avatar to upload.
return Promise.value(nil)
}
if oldGroupModel.groupAvatarData == avatarData && oldGroupModel.avatarUrlPath != nil {
// Skip redundant upload; the avatar hasn't changed.
return Promise.value(oldGroupModel.avatarUrlPath)
}
return firstly {
// Upload avatar.
return self.groupsV2.uploadGroupAvatar(avatarData: avatarData,
groupSecretParamsData: oldGroupModel.secretParamsData)
}.map(on: .global()) { (avatarUrlPath: String) throws -> String? in
// Convert Promise<String> to Promise<String?>
return avatarUrlPath
}
}.map(on: .global()) { (avatarUrlPath: String?) throws -> (UpdateInfo, GroupsV2ChangeSet) in
return try databaseStorage.read { transaction in
var proposedGroupModel = proposedGroupModel
if let avatarUrlPath = avatarUrlPath {
var builder = proposedGroupModel.asBuilder
builder.avatarUrlPath = avatarUrlPath
proposedGroupModel = try builder.buildAsV2(transaction: transaction)
}
let updateInfo = try self.updateInfoV2(oldGroupModel: oldGroupModel,
newGroupModel: proposedGroupModel,
newDMConfiguration: dmConfiguration,
transaction: transaction)
guard let newGroupModel = updateInfo.newGroupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid group model.")
}
// When building the change set, it's important that we
// diff the "new/proposed" group model against the "old"
// group model, not the "current" group model. This avoids
// reverting changes from other users.
//
// The "old" group model reflects the group model the user
// used as a point of departure for their updates. The
// "current" group model reflects the state of the database
// which may have changed since the user started editing.
// The "new" group model is the "old" model, updated to
// reflect the user intent.
//
// Let's say Alice and Bob edit the group at the same time.
// Alice changes the group name and Bob changes the group
// avatar. Alice updates the group first. When Bob's
// client tries to update, it should only reflect Bob's
// intent - to change the group avatar.
let changeSet = try self.groupsV2.buildChangeSet(oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
oldDMConfiguration: updateInfo.oldDMConfiguration,
newDMConfiguration: updateInfo.newDMConfiguration,
transaction: transaction)
return (updateInfo, changeSet)
}
}.then(on: .global()) { (_: UpdateInfo, changeSet: GroupsV2ChangeSet) throws -> Promise<TSGroupThread> in
return self.groupsV2.updateExistingGroupOnService(changeSet: changeSet)
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration,
description: "Update existing group") {
GroupsV2Error.timeout
}
}
// If dmConfiguration is nil, don't change the disappearing messages configuration.
private static func updateInfoV1(groupModel proposedGroupModel: TSGroupModel,
dmConfiguration: OWSDisappearingMessagesConfiguration?,
transaction: SDSAnyReadTransaction) throws -> UpdateInfo {
let groupId = proposedGroupModel.groupId
guard let thread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Thread does not exist.")
}
let currentGroupModel = thread.groupModel
guard currentGroupModel.groupsVersion == .V1 else {
throw OWSAssertionError("Invalid groupsVersion.")
}
guard let localAddress = tsAccountManager.localAddress else {
throw OWSAssertionError("Missing localAddress.")
}
let oldDMConfiguration = OWSDisappearingMessagesConfiguration.fetchOrBuildDefault(with: thread, transaction: transaction)
let newDMConfiguration = dmConfiguration ?? oldDMConfiguration
// Always ensure we're a member of any v1 group we're updating.
var builder = proposedGroupModel.groupMembership.asBuilder
builder.remove(localAddress)
builder.addNonPendingMember(localAddress, role: .normal)
let groupMembership = builder.build()
var groupModelBuilder = proposedGroupModel.asBuilder
groupModelBuilder.groupMembership = groupMembership
let newGroupModel = try groupModelBuilder.build(transaction: transaction)
if currentGroupModel.isEqual(to: newGroupModel, ignoreRevision: false) {
// Skip redundant update.
throw GroupsV2Error.redundantChange
}
return UpdateInfo(groupId: groupId,
newGroupModel: newGroupModel,
oldDMConfiguration: oldDMConfiguration,
newDMConfiguration: newDMConfiguration)
}
// If dmConfiguration is nil, don't change the disappearing messages configuration.
private static func updateInfoV2(oldGroupModel: TSGroupModelV2,
newGroupModel proposedGroupModel: TSGroupModelV2,
newDMConfiguration dmConfiguration: OWSDisappearingMessagesConfiguration?,
transaction: SDSAnyReadTransaction) throws -> UpdateInfo {
let groupId = proposedGroupModel.groupId
let proposedGroupMembership = proposedGroupModel.groupMembership
guard let thread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Thread does not exist.")
}
guard let currentGroupModel = thread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid groupModel.")
}
guard let localAddress = tsAccountManager.localAddress else {
throw OWSAssertionError("Missing localAddress.")
}
let oldDMConfiguration = OWSDisappearingMessagesConfiguration.fetchOrBuildDefault(with: thread, transaction: transaction)
let newDMConfiguration = dmConfiguration ?? oldDMConfiguration
for address in proposedGroupMembership.allUsers {
guard address.uuid != nil else {
throw OWSAssertionError("Group v2 member missing uuid.")
}
}
// Before we update a v2 group, we need to separate out the
// pending and non-pending members.
let groupMembership = self.separatePendingMembers(in: proposedGroupMembership,
oldGroupModel: oldGroupModel,
transaction: transaction)
guard groupMembership.nonPendingMembers.contains(localAddress) else {
throw OWSAssertionError("Missing localAddress.")
}
// Don't try to modify a v2 group if we're not a member.
guard groupMembership.nonPendingMembers.contains(localAddress) else {
throw OWSAssertionError("Missing localAddress.")
}
let hasAvatarUrlPath = proposedGroupModel.avatarUrlPath != nil
let hasAvatarData = proposedGroupModel.groupAvatarData != nil
guard hasAvatarUrlPath == hasAvatarData else {
throw OWSAssertionError("hasAvatarUrlPath: \(hasAvatarData) != hasAvatarData.")
}
// We don't need to increment the revision here,
// this is a "proposed" new group model; we'll
// eventually derive a new group model from
// protos received from the service and apply
// that the to the local database.
let newRevision = currentGroupModel.revision
var builder = proposedGroupModel.asBuilder
builder.groupMembership = groupMembership
builder.groupV2Revision = newRevision
let newGroupModel = try builder.build(transaction: transaction)
if currentGroupModel.isEqual(to: newGroupModel, ignoreRevision: false) {
// Skip redundant update.
throw GroupsV2Error.redundantChange
}
return UpdateInfo(groupId: groupId,
newGroupModel: newGroupModel,
oldDMConfiguration: oldDMConfiguration,
newDMConfiguration: newDMConfiguration)
}
// MARK: - Disappearing Messages
// This method works with v1 group threads and contact threads.
@objc
public static func remoteUpdateDisappearingMessages(withContactOrV1GroupThread thread: TSThread,
disappearingMessageToken: DisappearingMessageToken,
groupUpdateSourceAddress: SignalServiceAddress?,
transaction: SDSAnyWriteTransaction) {
guard !thread.isGroupV2Thread else {
owsFailDebug("Invalid thread.")
return
}
_ = self.updateDisappearingMessagesInDatabaseAndCreateMessages(token: disappearingMessageToken,
thread: thread,
shouldInsertInfoMessage: true,
groupUpdateSourceAddress: groupUpdateSourceAddress,
transaction: transaction)
}
public static func localUpdateDisappearingMessages(thread: TSThread,
disappearingMessageToken: DisappearingMessageToken) -> Promise<Void> {
let simpleUpdate = {
return databaseStorage.write(.promise) { transaction in
let updateResult = self.updateDisappearingMessagesInDatabaseAndCreateMessages(token: disappearingMessageToken,
thread: thread,
shouldInsertInfoMessage: true,
groupUpdateSourceAddress: nil,
transaction: transaction)
self.sendDisappearingMessagesConfigurationMessage(updateResult: updateResult,
thread: thread,
transaction: transaction)
}
}
guard let groupThread = thread as? TSGroupThread else {
return simpleUpdate()
}
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
return simpleUpdate()
}
return updateGroupV2(groupModel: groupModel,
description: "Update disappearing messages") { groupChangeSet in
groupChangeSet.setNewDisappearingMessageToken(disappearingMessageToken)
}.asVoid()
}
private struct UpdateDMConfigurationResult {
enum Action: UInt {
case updated
case unchanged
}
let action: Action
let oldDisappearingMessageToken: DisappearingMessageToken?
let newDisappearingMessageToken: DisappearingMessageToken
let newConfiguration: OWSDisappearingMessagesConfiguration
}
private static func updateDisappearingMessagesInDatabaseAndCreateMessages(token newToken: DisappearingMessageToken,
thread: TSThread,
shouldInsertInfoMessage: Bool,
groupUpdateSourceAddress: SignalServiceAddress?,
transaction: SDSAnyWriteTransaction) -> UpdateDMConfigurationResult {
let oldConfiguration = OWSDisappearingMessagesConfiguration.fetchOrBuildDefault(with: thread, transaction: transaction)
let oldToken = oldConfiguration.asToken
let hasUnsavedChanges = oldToken != newToken
guard hasUnsavedChanges else {
// Skip redundant updates.
return UpdateDMConfigurationResult(action: .unchanged,
oldDisappearingMessageToken: oldToken,
newDisappearingMessageToken: newToken,
newConfiguration: oldConfiguration)
}
let newConfiguration: OWSDisappearingMessagesConfiguration
if newToken.isEnabled {
newConfiguration = oldConfiguration.copyAsEnabled(withDurationSeconds: newToken.durationSeconds)
} else {
newConfiguration = oldConfiguration.copy(withIsEnabled: false)
}
newConfiguration.anyUpsert(transaction: transaction)
if shouldInsertInfoMessage {
var remoteContactName: String?
if let groupUpdateSourceAddress = groupUpdateSourceAddress,
groupUpdateSourceAddress.isValid,
!groupUpdateSourceAddress.isLocalAddress {
remoteContactName = contactsManager.displayName(for: groupUpdateSourceAddress, transaction: transaction)
}
let infoMessage = OWSDisappearingConfigurationUpdateInfoMessage(thread: thread,
configuration: newConfiguration,
createdByRemoteName: remoteContactName,
createdInExistingGroup: false)
infoMessage.anyInsert(transaction: transaction)
}
return UpdateDMConfigurationResult(action: .updated,
oldDisappearingMessageToken: oldToken,
newDisappearingMessageToken: newToken,
newConfiguration: newConfiguration)
}
private static func sendDisappearingMessagesConfigurationMessage(updateResult: UpdateDMConfigurationResult,
thread: TSThread,
transaction: SDSAnyWriteTransaction) {
guard updateResult.action == .updated else {
// The update was redundant, don't send an update message.
return
}
guard !thread.isGroupV2Thread else {
// Don't send DM configuration messages for v2 groups.
return
}
let newConfiguration = updateResult.newConfiguration
let message = OWSDisappearingMessagesConfigurationMessage(configuration: newConfiguration, thread: thread)
messageSenderJobQueue.add(message: message.asPreparer, transaction: transaction)
}
// MARK: - Accept Invites
public static func localAcceptInviteToGroupV2(groupModel: TSGroupModelV2) -> Promise<TSGroupThread> {
return updateGroupV2(groupModel: groupModel,
description: "Accept invite") { groupChangeSet in
groupChangeSet.setShouldAcceptInvite()
}
}
// MARK: - Leave Group / Decline Invite
public static func localLeaveGroupOrDeclineInvite(groupThread: TSGroupThread,
replacementAdminUuid: UUID? = nil) -> Promise<TSGroupThread> {
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
assert(replacementAdminUuid == nil)
return localLeaveGroupV1(groupId: groupThread.groupModel.groupId)
}
return localLeaveGroupV2OrDeclineInvite(groupModel: groupModel,
replacementAdminUuid: replacementAdminUuid)
}
private static func localLeaveGroupV1(groupId: Data) -> Promise<TSGroupThread> {
guard let localAddress = self.tsAccountManager.localAddress else {
return Promise(error: OWSAssertionError("Missing localAddress."))
}
return databaseStorage.write(.promise) { transaction in
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Missing group.")
}
let oldGroupModel = groupThread.groupModel
// Note that we consult allUsers which includes pending members.
guard oldGroupModel.groupMembership.allUsers.contains(localAddress) else {
throw OWSAssertionError("Local user is not a member of the group.")
}
sendGroupQuitMessage(inThread: groupThread, transaction: transaction)
let hasMessages = groupThread.numberOfInteractions(with: transaction) > 0
let infoMessagePolicy: InfoMessagePolicy = hasMessages ? .always : .never
var groupMembershipBuilder = oldGroupModel.groupMembership.asBuilder
groupMembershipBuilder.remove(localAddress)
let newGroupMembership = groupMembershipBuilder.build()
var builder = oldGroupModel.asBuilder
builder.groupMembership = newGroupMembership
let newGroupModel = try builder.build(transaction: transaction)
// We're leaving, so clear out who added us. If we're re-added it may change.
if newGroupModel.groupsVersion == .V1 {
newGroupModel.addedByAddress = nil
}
let groupUpdateSourceAddress = localAddress
let result = try self.updateExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: newGroupModel,
newDisappearingMessageToken: nil,
groupUpdateSourceAddress: groupUpdateSourceAddress,
infoMessagePolicy: infoMessagePolicy,
transaction: transaction)
return result.groupThread
}
}
private static func localLeaveGroupV2OrDeclineInvite(groupModel: TSGroupModelV2,
replacementAdminUuid: UUID? = nil) -> Promise<TSGroupThread> {
return updateGroupV2(groupModel: groupModel,
description: "Leave group or decline invite") { groupChangeSet in
groupChangeSet.setShouldLeaveGroupDeclineInvite()
// Sometimes when we leave a group we take care to assign a new admin.
if let replacementAdminUuid = replacementAdminUuid {
groupChangeSet.changeRoleForMember(replacementAdminUuid, role: .administrator)
}
}
}
@objc
public static func leaveGroupOrDeclineInviteAsyncWithoutUI(groupThread: TSGroupThread,
transaction: SDSAnyWriteTransaction,
success: (() -> Void)?) {
guard groupThread.isLocalUserPendingOrNonPendingMember else {
owsFailDebug("unexpectedly trying to leave group for which we're not a member.")
return
}
transaction.addAsyncCompletion {
firstly {
self.localLeaveGroupOrDeclineInvite(groupThread: groupThread).asVoid()
}.done { _ in
success?()
}.catch { error in
owsFailDebug("Leave group failed: \(error)")
}
}
}
// MARK: - Remove From Group / Revoke Invite
public static func removeFromGroupOrRevokeInviteV2(groupModel: TSGroupModelV2,
uuids: [UUID]) -> Promise<TSGroupThread> {
return updateGroupV2(groupModel: groupModel,
description: "Remove from group or revoke invite") { groupChangeSet in
for uuid in uuids {
groupChangeSet.removeMember(uuid)
}
}
}
// MARK: - Change Member Role
public static func changeMemberRoleV2(groupModel: TSGroupModelV2,
uuid: UUID,
role: TSGroupMemberRole) -> Promise<TSGroupThread> {
return updateGroupV2(groupModel: groupModel,
description: "Change member role") { groupChangeSet in
groupChangeSet.changeRoleForMember(uuid, role: role)
}
}
// MARK: - Change Group Access
public static func changeGroupAttributesAccessV2(groupModel: TSGroupModelV2,
access: GroupV2Access) -> Promise<TSGroupThread> {
return updateGroupV2(groupModel: groupModel,
description: "Change group attributes access") { groupChangeSet in
groupChangeSet.setAccessForAttributes(access)
}
}
public static func changeGroupMembershipAccessV2(groupModel: TSGroupModelV2,
access: GroupV2Access) -> Promise<TSGroupThread> {
return updateGroupV2(groupModel: groupModel,
description: "Change group membership access") { groupChangeSet in
groupChangeSet.setAccessForMembers(access)
}
}
// MARK: - Generic Group Change
public static func updateGroupV2(groupModel: TSGroupModelV2,
description: String,
changeSetBlock: @escaping (GroupsV2ChangeSet) -> Void) -> Promise<TSGroupThread> {
return firstly {
return self.ensureLocalProfileHasCommitmentIfNecessary()
}.then(on: .global()) { () throws -> Promise<TSGroupThread> in
return self.groupsV2.updateGroupV2(groupModel: groupModel, changeSetBlock: changeSetBlock)
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration,
description: description) {
GroupsV2Error.timeout
}
}
// MARK: - Removed from Group or Invite Revoked
public static func handleNotInGroup(groupId: Data,
transaction: SDSAnyWriteTransaction) {
guard let localAddress = tsAccountManager.localAddress else {
owsFailDebug("Missing localAddress.")
return
}
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
// Local user may have just deleted the thread via the UI.
// Or we maybe be trying to restore a group from storage service
// that we are no longer a member of.
Logger.warn("Missing group in database.")
return
}
// Remove local user from group.
// We do _not_ bump the revision number since this (unlike all other
// changes to group state) is inferred from a 403. This is fine; if
// we're ever re-added to the group the groups v2 machinery will
// recover.
var groupMembershipBuilder = groupThread.groupModel.groupMembership.asBuilder
groupMembershipBuilder.remove(localAddress)
var groupModelBuilder = groupThread.groupModel.asBuilder
do {
groupModelBuilder.groupMembership = groupMembershipBuilder.build()
let newGroupModel = try groupModelBuilder.build(transaction: transaction)
// groupUpdateSourceAddress is nil because we don't (and can't) know who
// removed us or revoked our invite.
//
// newDisappearingMessageToken is nil because we don't want to change
// DM state.
_ = try updateExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: newGroupModel,
newDisappearingMessageToken: nil,
groupUpdateSourceAddress: nil,
infoMessagePolicy: .always,
transaction: transaction)
} catch {
owsFailDebug("Error: \(error)")
}
}
// MARK: - UUIDs
public static func tryToEnableGroupsV2(for addresses: [SignalServiceAddress],
isBlocking: Bool,
ignoreErrors: Bool) -> Promise<Void> {
let promise = tryToEnableGroupsV2(for: addresses, isBlocking: isBlocking)
if ignoreErrors {
return promise.recover { error -> Guarantee<Void> in
Logger.warn("Error: \(error).")
return Guarantee.value(())
}
} else {
return promise
}
}
private static func tryToEnableGroupsV2(for addresses: [SignalServiceAddress],
isBlocking: Bool) -> Promise<Void> {
return firstly { () -> Promise<Void> in
for address in addresses {
guard address.isValid else {
throw OWSAssertionError("Invalid address: \(address).")
}
}
return Promise.value(())
}.then(on: .global()) { _ -> Promise<Void> in
return self.tryToFillInMissingUuuids(for: addresses, isBlocking: isBlocking)
}.then(on: .global()) { _ -> Promise<Void> in
return self.tryToEnableGroupsV2Capability(for: addresses, isBlocking: isBlocking)
}
}
public static func tryToFillInMissingUuuids(for addresses: [SignalServiceAddress],
isBlocking: Bool) -> Promise<Void> {
guard FeatureFlags.useOnlyModernContactDiscovery ||
FeatureFlags.compareLegacyContactDiscoveryAgainstModern else {
// Can't fill in UUIDs using legacy contact intersections.
return Promise.value(())
}
let phoneNumbersWithoutUuids = addresses.filter { $0.uuid == nil }.compactMap { $0.phoneNumber }
guard phoneNumbersWithoutUuids.count > 0 else {
return Promise.value(())
}
if isBlocking {
// Block on the outcome.
return contactsUpdater.lookupIdentifiersPromise(phoneNumbers:
phoneNumbersWithoutUuids).asVoid()
} else {
// This will throttle, de-bounce, etc.
self.bulkUUIDLookup.lookupUuids(phoneNumbers: phoneNumbersWithoutUuids)
return Promise.value(())
}
}
private static func tryToEnableGroupsV2Capability(for addresses: [SignalServiceAddress],
isBlocking: Bool) -> Promise<Void> {
return firstly { () -> Promise<[SignalServiceAddress]> in
let validAddresses = addresses.filter { $0.isValid }
if validAddresses.count < addresses.count {
owsFailDebug("Invalid addresses.")
}
return Promise.value(validAddresses)
}.then(on: .global()) { (addresses: [SignalServiceAddress]) -> Promise<Void> in
// Try to ensure groups v2 capability.
var addressesWithoutCapability = [SignalServiceAddress]()
self.databaseStorage.read { transaction in
for address in addresses {
if !GroupManager.doesUserHaveGroupsV2Capability(address: address, transaction: transaction) {
addressesWithoutCapability.append(address)
}
}
}
guard !addressesWithoutCapability.isEmpty else {
return Promise.value(())
}
if isBlocking {
// Block on the outcome of the profile updates.
var promises = [Promise<Void>]()
for address in addressesWithoutCapability {
promises.append(self.profileManager.fetchProfile(forAddressPromise: address).asVoid())
}
return when(fulfilled: promises)
} else {
// This will throttle, de-bounce, etc.
self.bulkProfileFetch.fetchProfiles(addresses: addressesWithoutCapability)
return Promise.value(())
}
}
}
// MARK: - Messages
@objc
public static func sendGroupUpdateMessageObjc(thread: TSGroupThread) -> AnyPromise {
return AnyPromise(self.sendGroupUpdateMessage(thread: thread))
}
public static func sendGroupUpdateMessage(thread: TSGroupThread,
changeActionsProtoData: Data? = nil) -> Promise<Void> {
guard !thread.isGroupV2Thread || !DebugFlags.groupsV2dontSendUpdates else {
return Promise.value(())
}
return databaseStorage.read(.promise) { transaction in
let expiresInSeconds = thread.disappearingMessagesDuration(with: transaction)
let messageBuilder = TSOutgoingMessageBuilder(thread: thread)
messageBuilder.expiresInSeconds = expiresInSeconds
// V2 group update messages mostly ignore groupMetaMessage,
// but we set it to get the right behavior in shouldBeSaved.
// i.e. we need to flag this message as a group update that
// is "durable but transient" - it should not be saved.
messageBuilder.groupMetaMessage = .update
if thread.isGroupV2Thread {
if FeatureFlags.groupsV2embedProtosInGroupUpdates {
messageBuilder.changeActionsProtoData = changeActionsProtoData
}
self.addAdditionalRecipients(to: messageBuilder,
groupThread: thread,
transaction: transaction)
}
return messageBuilder.build()
}.then(on: .global()) { (message: TSOutgoingMessage) throws -> Promise<Void> in
let groupModel = thread.groupModel
// V1 group updates need to include the group avatar (if any)
// as an attachment.
if thread.isGroupV1Thread,
let avatarData = groupModel.groupAvatarData,
avatarData.count > 0 {
if let dataSource = DataSourceValue.dataSource(with: avatarData, fileExtension: "png") {
// DURABLE CLEANUP - currently one caller uses the completion handler to delete the tappable error message
// which causes this code to be called. Once we're more aggressive about durable sending retry,
// we could get rid of this "retryable tappable error message".
return firstly {
self.messageSender.sendTemporaryAttachment(.promise,
dataSource: dataSource,
contentType: OWSMimeTypeImagePng,
message: message)
}.done(on: .global()) { _ in
Logger.debug("Successfully sent group update with avatar")
}.recover(on: .global()) { error in
Logger.error("Error sending v1 group avatar update: \(error)")
if message.wasSentToAnyRecipient {
// If a v1 group update was successfully sent to any
// group member, consider it a success.
} else {
throw error
}
}
}
}
// DURABLE CLEANUP - currently one caller uses the completion handler to delete the tappable error message
// which causes this code to be called. Once we're more aggressive about durable sending retry,
// we could get rid of this "retryable tappable error message".
return firstly {
return self.messageSender.sendMessage(.promise, message.asPreparer)
}.recover(on: .global()) { error in
if thread.isGroupV1Thread {
Logger.error("Error sending v1 group update: \(error)")
if message.wasSentToAnyRecipient {
// If a v1 group update was successfully sent to any
// group member, consider it a success.
} else {
throw error
}
} else {
// Failure to send a "group update" message should not
// be considered a failure when updating a v2 group.
Logger.error("Error sending v2 group update: \(error)")
}
}
}
}
private static func sendDurableNewGroupMessage(forThread thread: TSGroupThread) -> Promise<Void> {
guard !thread.isGroupV2Thread || !DebugFlags.groupsV2dontSendUpdates else {
return Promise.value(())
}
return firstly {
databaseStorage.write(.promise) { transaction in
let expiresInSeconds = thread.disappearingMessagesDuration(with: transaction)
let messageBuilder = TSOutgoingMessageBuilder(thread: thread)
messageBuilder.groupMetaMessage = .new
messageBuilder.expiresInSeconds = expiresInSeconds
self.addAdditionalRecipients(to: messageBuilder,
groupThread: thread,
transaction: transaction)
let message = messageBuilder.build()
self.messageSenderJobQueue.add(message: message.asPreparer,
transaction: transaction)
}
}.then(on: .global()) { _ -> Promise<Void> in
// The "new group" update message for v1 groups doesn't support avatars.
// So, if a new v1 group has an avatar, we need to send a group update
// message.
guard thread.groupModel.groupsVersion == .V1,
thread.groupModel.groupAvatarData != nil else {
return Promise.value(())
}
return self.sendGroupUpdateMessage(thread: thread)
}
}
private static func addAdditionalRecipients(to messageBuilder: TSOutgoingMessageBuilder,
groupThread: TSGroupThread,
transaction: SDSAnyReadTransaction) {
guard groupThread.groupModel.groupsVersion == .V2 else {
// No need to add "additional recipients" to v1 groups.
return
}
// We need to send v2 group updates to pending members
// as well. Normal group sends only include "full members".
assert(messageBuilder.additionalRecipients == nil)
let additionalRecipients = groupThread.groupModel.groupMembership.pendingMembers.filter { address in
return doesUserSupportGroupsV2(address: address,
transaction: transaction)
}
messageBuilder.additionalRecipients = Array(additionalRecipients)
}
@objc
public static func shouldMessageHaveAdditionalRecipients(_ message: TSOutgoingMessage,
groupThread: TSGroupThread) -> Bool {
guard groupThread.groupModel.groupsVersion == .V2 else {
return false
}
switch message.groupMetaMessage {
case .update, .new:
return true
default:
return false
}
}
private static func sendGroupQuitMessage(inThread groupThread: TSGroupThread,
transaction: SDSAnyWriteTransaction) {
guard groupThread.groupModel.groupsVersion == .V1 else {
return
}
let message = TSOutgoingMessage(in: groupThread,
groupMetaMessage: .quit,
expiresInSeconds: 0)
messageSenderJobQueue.add(message: message.asPreparer, transaction: transaction)
}
public static func resendInvite(groupThread: TSGroupThread,
transaction: SDSAnyWriteTransaction) {
guard groupThread.groupModel.groupsVersion == .V2 else {
return
}
let messageBuilder = TSOutgoingMessageBuilder(thread: groupThread)
// V2 group update messages mostly ignore groupMetaMessage,
// but we set it to get the right behavior in shouldBeSaved.
// i.e. we need to flag this message as a group update that
// is "durable but transient" - it should not be saved.
messageBuilder.groupMetaMessage = .update
// We need to send v2 group updates to pending members
// as well. Normal group sends only include "full members".
assert(messageBuilder.additionalRecipients == nil)
let additionalRecipients = groupThread.groupModel.groupMembership.pendingMembers.filter { address in
return doesUserSupportGroupsV2(address: address,
transaction: transaction)
}
messageBuilder.additionalRecipients = Array(additionalRecipients)
let message = messageBuilder.build()
messageSender.sendMessage(message.asPreparer,
success: {
Logger.info("Successfully sent message.")
},
failure: { error in
owsFailDebug("Failed to send message with error: \(error)")
})
}
// MARK: - Group Database
@objc
public enum InfoMessagePolicy: UInt {
case always
case insertsOnly
case updatesOnly
case never
}
// If disappearingMessageToken is nil, don't update the disappearing messages configuration.
public static func insertGroupThreadInDatabaseAndCreateInfoMessage(groupModel: TSGroupModel,
disappearingMessageToken: DisappearingMessageToken?,
groupUpdateSourceAddress: SignalServiceAddress?,
infoMessagePolicy: InfoMessagePolicy = .always,
transaction: SDSAnyWriteTransaction) -> TSGroupThread {
let groupThread = TSGroupThread(groupModelPrivate: groupModel)
groupThread.anyInsert(transaction: transaction)
let newDisappearingMessageToken = disappearingMessageToken ?? DisappearingMessageToken.disabledToken
switch infoMessagePolicy {
case .always, .insertsOnly:
insertGroupUpdateInfoMessage(groupThread: groupThread,
oldGroupModel: nil,
newGroupModel: groupModel,
oldDisappearingMessageToken: nil,
newDisappearingMessageToken: newDisappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
transaction: transaction)
default:
break
}
notifyStorageServiceOfInsertedGroup(groupModel: groupModel,
transaction: transaction)
return groupThread
}
// If newDisappearingMessageToken is nil, don't update the disappearing messages configuration.
public static func tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: TSGroupModel,
newDisappearingMessageToken: DisappearingMessageToken?,
groupUpdateSourceAddress: SignalServiceAddress?,
canInsert: Bool,
infoMessagePolicy: InfoMessagePolicy = .always,
transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult {
let threadId = TSGroupThread.threadId(fromGroupId: newGroupModel.groupId)
guard TSGroupThread.anyExists(uniqueId: threadId, transaction: transaction) else {
guard canInsert else {
throw OWSAssertionError("Missing groupThread.")
}
// This thread didn't previously exist, so if we're a member we
// have to assume we were just added.
var wasAddedToGroup = false
if newGroupModel.groupsVersion == .V1, let localAddress = tsAccountManager.localAddress, newGroupModel.groupMembers.contains(localAddress) {
newGroupModel.addedByAddress = groupUpdateSourceAddress
wasAddedToGroup = true
}
let thread = insertGroupThreadInDatabaseAndCreateInfoMessage(groupModel: newGroupModel,
disappearingMessageToken: newDisappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
infoMessagePolicy: infoMessagePolicy,
transaction: transaction)
// Auto-accept the message request for this group if we were added by someone we trust.
if wasAddedToGroup, let addedByAddress = groupUpdateSourceAddress,
profileManager.isUser(inProfileWhitelist: addedByAddress, transaction: transaction) {
profileManager.addGroupId(toProfileWhitelist: newGroupModel.groupId, wasLocallyInitiated: true, transaction: transaction)
}
return UpsertGroupResult(action: .inserted, groupThread: thread)
}
return try updateExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: newGroupModel,
newDisappearingMessageToken: newDisappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
infoMessagePolicy: infoMessagePolicy,
transaction: transaction)
}
// If newDisappearingMessageToken is nil, don't update the disappearing messages configuration.
public static func updateExistingGroupThreadInDatabaseAndCreateInfoMessage(newGroupModel: TSGroupModel,
newDisappearingMessageToken: DisappearingMessageToken?,
groupUpdateSourceAddress: SignalServiceAddress?,
infoMessagePolicy: InfoMessagePolicy = .always,
transaction: SDSAnyWriteTransaction) throws -> UpsertGroupResult {
// Step 1: First reload latest thread state. This ensures:
//
// * The thread (still) exists in the database.
// * The update is working off latest database state.
//
// We always have the groupThread at the call sites of this method, but this
// future-proofs us against bugs.
let groupId = newGroupModel.groupId
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("Missing groupThread.")
}
// Step 2: Update DM configuration in database, if necessary.
let updateDMResult: UpdateDMConfigurationResult
if let newDisappearingMessageToken = newDisappearingMessageToken {
// shouldInsertInfoMessage is false because we only want to insert a
// single info message if we update both DM config and thread model.
updateDMResult = updateDisappearingMessagesInDatabaseAndCreateMessages(token: newDisappearingMessageToken,
thread: groupThread,
shouldInsertInfoMessage: false,
groupUpdateSourceAddress: groupUpdateSourceAddress,
transaction: transaction)
} else {
let oldConfiguration = OWSDisappearingMessagesConfiguration.fetchOrBuildDefault(with: groupThread, transaction: transaction)
let oldToken = oldConfiguration.asToken
updateDMResult = UpdateDMConfigurationResult(action: .unchanged,
oldDisappearingMessageToken: oldToken,
newDisappearingMessageToken: oldToken,
newConfiguration: oldConfiguration)
}
// Step 3: Update group in database, if necessary.
let oldGroupModel = groupThread.groupModel
let updateThreadResult: UpsertGroupResult = {
if let newGroupModelV2 = newGroupModel as? TSGroupModelV2,
let oldGroupModelV2 = oldGroupModel as? TSGroupModelV2 {
guard newGroupModelV2.revision >= oldGroupModelV2.revision else {
// This is a key check. We never want to revert group state
// in the database to an earlier revision. Races are
// unavoidable: there are multiple triggers for group updates
// and some of them require interacting with the service.
// Ultimately it is safe to ignore stale writes and treat
// them as successful.
//
// NOTE: As always, we return the group thread with the
// latest group state.
Logger.warn("Skipping stale update for v2 group.")
return UpsertGroupResult(action: .unchanged, groupThread: groupThread)
}
}
guard !oldGroupModel.isEqual(to: newGroupModel, ignoreRevision: false) else {
// Skip redundant update.
return UpsertGroupResult(action: .unchanged, groupThread: groupThread)
}
let hasUserFacingChange = !oldGroupModel.isEqual(to: newGroupModel, ignoreRevision: true)
// If we weren't previously a member and are now a member, assume whoever
// triggered this update added us to the group.
if newGroupModel.groupsVersion == .V1,
let localAddress = tsAccountManager.localAddress,
!oldGroupModel.groupMembers.contains(localAddress),
newGroupModel.groupMembers.contains(localAddress) {
newGroupModel.addedByAddress = groupUpdateSourceAddress
// Auto-accept the message request for this group if we were added by someone we trust.
if let addedByAddress = groupUpdateSourceAddress,
profileManager.isUser(inProfileWhitelist: addedByAddress, transaction: transaction) {
profileManager.addGroupId(toProfileWhitelist: newGroupModel.groupId, wasLocallyInitiated: true, transaction: transaction)
}
}
groupThread.update(with: newGroupModel, transaction: transaction)
let action: UpsertGroupResult.Action = (hasUserFacingChange
? .updatedWithUserFacingChanges
: .updatedWithoutUserFacingChanges)
return UpsertGroupResult(action: action, groupThread: groupThread)
}()
if updateDMResult.action == .unchanged &&
(updateThreadResult.action == .unchanged ||
updateThreadResult.action == .updatedWithoutUserFacingChanges) {
// Neither DM config nor thread model had user-facing changes.
return updateThreadResult
}
switch infoMessagePolicy {
case .always, .updatesOnly:
insertGroupUpdateInfoMessage(groupThread: groupThread,
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel,
oldDisappearingMessageToken: updateDMResult.oldDisappearingMessageToken,
newDisappearingMessageToken: updateDMResult.newDisappearingMessageToken,
groupUpdateSourceAddress: groupUpdateSourceAddress,
transaction: transaction)
default:
break
}
return UpsertGroupResult(action: .updatedWithUserFacingChanges, groupThread: groupThread)
}
// MARK: - Storage Service
private static func notifyStorageServiceOfInsertedGroup(groupModel: TSGroupModel,
transaction: SDSAnyReadTransaction) {
guard let groupModel = groupModel as? TSGroupModelV2 else {
// We only need to notify the storage service about v2 groups.
return
}
guard !groupsV2.isGroupKnownToStorageService(groupModel: groupModel,
transaction: transaction) else {
// To avoid redundant storage service writes,
// don't bother notifying the storage service
// about v2 groups it already knows about.
return
}
storageServiceManager.recordPendingUpdates(groupModel: groupModel)
}
// MARK: - "Group Update" Info Messages
// NOTE: This should only be called by GroupManager and by DebugUI.
public static func insertGroupUpdateInfoMessage(groupThread: TSGroupThread,
oldGroupModel: TSGroupModel?,
newGroupModel: TSGroupModel,
oldDisappearingMessageToken: DisappearingMessageToken?,
newDisappearingMessageToken: DisappearingMessageToken,
groupUpdateSourceAddress: SignalServiceAddress?,
transaction: SDSAnyWriteTransaction) {
var userInfo: [InfoMessageUserInfoKey: Any] = [
.newGroupModel: newGroupModel,
.newDisappearingMessageToken: newDisappearingMessageToken
]
if let oldGroupModel = oldGroupModel {
userInfo[.oldGroupModel] = oldGroupModel
}
if let oldDisappearingMessageToken = oldDisappearingMessageToken {
userInfo[.oldDisappearingMessageToken] = oldDisappearingMessageToken
}
if let groupUpdateSourceAddress = groupUpdateSourceAddress {
userInfo[.groupUpdateSourceAddress] = groupUpdateSourceAddress
}
let infoMessage = TSInfoMessage(thread: groupThread,
messageType: .typeGroupUpdate,
infoMessageUserInfo: userInfo)
infoMessage.anyInsert(transaction: transaction)
}
// MARK: - Group Database
@objc
public static let groupsV2CapabilityStore = SDSKeyValueStore(collection: "GroupManager.groupsV2Capability")
@objc
public static func doesUserHaveGroupsV2Capability(address: SignalServiceAddress,
transaction: SDSAnyReadTransaction) -> Bool {
if DebugFlags.groupsV2IgnoreCapability {
return true
}
if let uuid = address.uuid {
if groupsV2CapabilityStore.getBool(uuid.uuidString, defaultValue: false, transaction: transaction) {
return true
}
}
return false
}
@objc
public static func setUserHasGroupsV2Capability(address: SignalServiceAddress,
value: Bool,
transaction: SDSAnyWriteTransaction) {
if let uuid = address.uuid {
groupsV2CapabilityStore.setBool(value, key: uuid.uuidString, transaction: transaction)
}
}
// MARK: - Profiles
@objc
public static func updateProfileWhitelist(withGroupThread groupThread: TSGroupThread) {
guard let localAddress = self.tsAccountManager.localAddress else {
owsFailDebug("Missing localAddress.")
return
}
// Ensure the thread and all members of the group are in our profile whitelist
// if we're a member of the group. We don't want to do this if we're just a
// pending member or are leaving/have already left the group.
let groupMembership = groupThread.groupModel.groupMembership
guard groupMembership.isNonPendingMember(localAddress) else {
return
}
profileManager.addThread(toProfileWhitelist: groupThread)
}
@objc
public static func storeProfileKeysFromGroupProtos(_ profileKeysByUuid: [UUID: Data]) {
var profileKeysByAddress = [SignalServiceAddress: Data]()
for (uuid, profileKeyData) in profileKeysByUuid {
profileKeysByAddress[SignalServiceAddress(uuid: uuid)] = profileKeyData
}
// If we receive a profile key from a user, that's "authoritative" and
// can discard and previous key from them.
//
// However, if we learn of a user's profile key from v2 group protos,
// it might be stale. E.g. maybe they were added by someone who
// doesn't know their new profile key. So we only want to fill in
// missing keys, not overwrite any existing keys.
profileManager.fillInMissingProfileKeys(profileKeysByAddress)
}
public static func ensureLocalProfileHasCommitmentIfNecessary() -> Promise<Void> {
guard tsAccountManager.isOnboarded() else {
return Promise.value(())
}
guard let localAddress = self.tsAccountManager.localAddress else {
return Promise(error: OWSAssertionError("Missing localAddress."))
}
guard RemoteConfig.versionedProfileUpdate else {
// We don't need a profile key credential for the local user
// if we're not even going to try to create a v2 group.
if RemoteConfig.groupsV2GoodCitizen {
owsFailDebug("Can't participate in v2 groups without a profile key commitment.")
}
return Promise.value(())
}
return databaseStorage.read(.promise) { transaction -> Bool in
return self.groupsV2.hasProfileKeyCredential(for: localAddress,
transaction: transaction)
}.then(on: .global()) { hasLocalCredential -> Promise<Void> in
guard !hasLocalCredential else {
return Promise.value(())
}
guard tsAccountManager.isRegisteredPrimaryDevice else {
// On secondary devices, just re-fetch the local
// profile.
return self.profileManager.fetchLocalUsersProfilePromise().asVoid()
}
// We (and other clients) need a profile key credential for
// all group members to use groups v2. Other clients can't
// request our profile key credential from the service until
// until we've uploaded a profile key commitment to the service.
//
// If we've never done a versioned profile update, try to do so now.
// This step might or might not be necessary. It's simpler and safer
// to always do it. It won't amount to much extra work and we'll
// probably do it at most once. Once we have a profile key credential
// for the local user (which should last forever) we'll abort above.
// Group v2 actions will use tryToEnsureProfileKeyCredentials()
// and we want to set them up to succeed.
return self.groupsV2.reuploadLocalProfilePromise()
}
}
// MARK: - Network Errors
static func isNetworkFailureOrTimeout(_ error: Error) -> Bool {
if IsNetworkConnectivityFailure(error) {
return true
}
switch error {
case GroupsV2Error.timeout:
return true
default:
return false
}
}
}
// MARK: -
public extension GroupManager {
class func messageProcessingPromise(for thread: TSThread,
description: String) -> Promise<Void> {
guard thread.isGroupV2Thread else {
return Promise.value(())
}
return messageProcessingPromise(description: description)
}
class func messageProcessingPromise(for groupModel: TSGroupModel,
description: String) -> Promise<Void> {
guard groupModel.groupsVersion == .V2 else {
return Promise.value(())
}
return messageProcessingPromise(description: description)
}
private class func messageProcessingPromise(description: String) -> Promise<Void> {
return firstly {
self.messageProcessing.allMessageFetchingAndProcessingPromise()
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration,
description: description) {
GroupsV2Error.timeout
}
}
}
// MARK: -
public extension Error {
var isNetworkFailureOrTimeout: Bool {
return GroupManager.isNetworkFailureOrTimeout(self)
}
}