|
|
|
|
Quelle GroupManager.swift
Sprache: JAVA
|
|
import CocoaLumberjackSwift
import Foundation
import ThreemaEssentials
public final class GroupManager: NSObject, GroupManagerProtocol {
public enum GroupError: Error {
case creatorIsBlocked(groupIdentity: GroupIdentity)
case creatorNotFound
case membersMissing
case groupConversationNotFound
case groupNotFound
case decodingFailed
case blobIDOrKeyMissing
case photoUploadFailed
case notCreator
case contactForCreatorMissing
case contactForMemberMissing
case groupSyncNewMembersFailed
}
/// Used for error handling when fetching unknown contacts
private enum FetchedContactOrError: Equatable {
case added
case revokedOrInvalid(String)
case blocked(String)
case localNotFound
case error
}
private let myIdentityStore: MyIdentityStoreProtocol
private let contactStore: ContactStoreProtocol
private let taskManager: TaskManagerProtocol
private let userSettings: UserSettingsProtocol
private let entityManager: EntityManager
private let groupPhotoSender: () -> (GroupPhotoSenderProtocol)
init(
myIdentityStore: MyIdentityStoreProtocol,
contactStore: ContactStoreProtocol,
taskManager: TaskManagerProtocol,
userSettings: UserSettingsProtocol,
entityManager: EntityManager,
groupPhotoSender: @escaping (() -> (GroupPhotoSenderProtocol))
) {
self.myIdentityStore = myIdentityStore
self.contactStore = contactStore
self.taskManager = taskManager
self.userSettings = userSettings
self.entityManager = entityManager
self.groupPhotoSender = groupPhotoSender
}
convenience init(entityManager: EntityManager, taskManager: TaskManagerProtocol = TaskManager()) {
self.init(
myIdentityStore: MyIdentityStore.shared(),
contactStore: ContactStore.shared(),
taskManager: taskManager,
userSettings: UserSettings.shared(),
entityManager: entityManager,
groupPhotoSender: {
GroupPhotoSender()
}
)
}
@available(swift, obsoleted: 1.0, renamed: "init(entityManager:taskManager:)", message: "Only use from Objective-C")
@objc convenience init(entityManager: EntityManager, taskManagerObjc: TaskManager) {
self.init(
myIdentityStore: MyIdentityStore.shared(),
contactStore: ContactStore.shared(),
taskManager: taskManagerObjc,
userSettings: UserSettings.shared(),
entityManager: entityManager,
groupPhotoSender: {
GroupPhotoSender()
}
)
}
// MARK: - Create or update
/// Create or update group members and send group create messages to members, if I'm the creator.
///
/// Also sends ballot messages to new members, if necessary.
///
/// - Parameters:
/// - groupIdentity: Identity of the group
/// - members: Members (identity list) of the group
/// - systemMessageDate: Date for new system message(s)
/// - Returns: Group and list of new members (identity)
/// - Throws: ThreemaError, GroupError.notCreator, TaskManagerError
public func createOrUpdate(
for groupIdentity: GroupIdentity,
members: Set<String>,
systemMessageDate: Date
) async throws -> (Group, Set<String>?) {
guard groupIdentity.creator.rawValue.elementsEqual(myIdentityStore.identity) else {
throw GroupError.notCreator
}
// Is oldMembers nil, means the group is new and there aren't old members
var oldMembers: [String]?
var removedMembers = [String]()
// If group already exists get old and removed members
entityManager.performAndWait {
if let oldConversation = self.getConversation(for: groupIdentity) {
oldMembers = oldConversation.unwrappedMembers.map(\.identity)
removedMembers = oldMembers!.filter { !members.contains($0) }
}
}
guard let group = try await createOrUpdateDB(
for: groupIdentity,
members: members,
systemMessageDate: systemMessageDate,
sourceCaller: .local
) else {
throw GroupError.groupNotFound
}
var newMembers: Set<String>?
await entityManager.perform {
if let oldMembers,
let conversation = self.getConversation(for: GroupIdentity(
id: group.groupID,
creator: group.groupIdentity.creator
)) {
newMembers = Set(
conversation.unwrappedMembers
.filter { !oldMembers.contains($0.identity) }
.map(\.identity)
)
if newMembers?.isEmpty == true {
newMembers = nil
}
}
}
if groupIdentity.creator.rawValue.elementsEqual(myIdentityStore.identity) {
// Send group create message to each active member
let task = TaskDefinitionSendGroupCreateMessage(
group: group,
to: group.allActiveMemberIdentitiesWithoutCreator,
removed: removedMembers,
members: members
)
taskManager.add(taskDefinition: task)
}
// Synchronize only new members
if let newMembers {
do {
try await sync(group: group, to: newMembers, withoutCreateMessage: true)
}
catch {
DDLogError("Unable to sync group for new group members")
throw GroupError.groupSyncNewMembersFailed
}
}
return (group, newMembers)
}
/// Create or update group members in DB.
/// - Parameters:
/// - groupIdentity: Identity of the group
/// - members: Members (identity list) of the group
/// - systemMessageDate: Date for new system message(s), if `nil` no message is posted
/// - sourceCaller: Delete member (is hidden contact) only is not `SourceCaller.sync`
/// - Returns: Created or updated group or is Nil when group is deleted
/// - Throws: GroupError.contactForCreatorMissing, GroupError.contactForMemberMissing
@discardableResult
public func createOrUpdateDB(
for groupIdentity: GroupIdentity,
members: Set<String>,
systemMessageDate: Date?,
sourceCaller: SourceCaller
) async throws -> Group? {
if !groupIdentity.creator.rawValue.elementsEqual(myIdentityStore.identity) {
// Record a pseudo sync request so we won't trigger another one if we process
// messages in this new group while we are still processing the group create
recordSendSyncRequest(groupIdentity)
}
// Am I the creator? Then Conversation.contact and GroupEntity.groupCreator have to be `nil`.
var creatorContact: ContactEntity?
if !groupIdentity.creator.rawValue.elementsEqual(myIdentityStore.identity) {
// If the creator blocked and group not found, then send leave messages to sender and all provided members
if userSettings.blacklist.contains(groupIdentity.creator.rawValue),
getGroup(groupIdentity.id, creator: groupIdentity.creator.rawValue) == nil {
if members.contains(myIdentityStore.identity) {
var toMembers = [String](members)
if !toMembers.contains(groupIdentity.creator.rawValue) {
toMembers.append(groupIdentity.creator.rawValue)
}
leave(groupIdentity: groupIdentity, toMembers: toMembers)
}
DDLogWarn(
"Group (\(groupIdentity)) not created, because creator is blocked, i sent group leave messages to its members"
)
throw GroupError.creatorIsBlocked(groupIdentity: groupIdentity)
}
creatorContact = entityManager.entityFetcher.contactEntity(for: groupIdentity.creator.rawValue)
guard creatorContact != nil else {
throw GroupError.contactForCreatorMissing
}
}
// Adjust group members and group state
var allMembers = Set<String>(members)
if !allMembers.contains(groupIdentity.creator.rawValue) {
allMembers.insert(groupIdentity.creator.rawValue)
}
if allMembers.contains(myIdentityStore.identity) {
// I'm member of this group
var groupNewCreated = false
// First fetch all contacts
var identitiesToFetch = [String]()
entityManager.performAndWait {
for member in members.filter({ $0 != self.myIdentityStore.identity }) {
if self.entityManager.entityFetcher.contactEntity(for: member) == nil {
identitiesToFetch.append(member)
}
}
}
let fetchedIdentities = try await fetchContacts(for: identitiesToFetch)
return try await entityManager.performSave {
let conversation: ConversationEntity
if let existingConversation = self.entityManager.entityFetcher.conversationEntity(
for: groupIdentity,
myIdentity: self.myIdentityStore.identity
) {
conversation = existingConversation
}
else {
conversation = self.entityManager.entityCreator.conversationEntity()
conversation.groupID = groupIdentity.id
conversation.contact = creatorContact
conversation.groupMyIdentity = self.myIdentityStore.identity
}
let groupEntity: GroupEntity
if let existingGroup = self.entityManager.entityFetcher.groupEntity(for: conversation) {
groupEntity = existingGroup
}
else {
groupEntity = self.entityManager.entityCreator.groupEntity(
groupID: groupIdentity.id,
state: NSNumber(value: GroupEntity.GroupState.active.rawValue)
)
groupEntity.groupCreator = creatorContact != nil ? groupIdentity.creator.rawValue : nil
groupNewCreated = true
}
groupEntity.lastPeriodicSync = Date()
let currentMembers: [String] = conversation.unwrappedMembers.map(\.identity)
// I am member of this group, set group state active
if groupEntity.state != NSNumber(value: GroupEntity.GroupState.active.rawValue) {
groupEntity.state = NSNumber(value: GroupEntity.GroupState.active.rawValue)
if let systemMessageDate {
self.postSystemMessage(
in: conversation,
type: .groupSelfAdded,
arg: nil,
date: systemMessageDate
)
}
}
// My ID should be set on active group conversation (could be an old ID e.g. after restored a
// backup)
if let groupMyIdentity = conversation.groupMyIdentity,
!groupMyIdentity.elementsEqual(self.myIdentityStore.identity) {
conversation.groupMyIdentity = self.myIdentityStore.identity
}
// Remove deleted members
for memberIdentity in currentMembers {
guard !allMembers.contains(memberIdentity) else {
continue
}
if let memberContact = self.entityManager.entityFetcher.contactEntity(for: memberIdentity) {
conversation.members?.remove(memberContact)
if let systemMessageDate {
self.postSystemMessage(
in: conversation,
member: memberContact,
type: .groupMemberForcedLeave,
date: systemMessageDate
)
}
}
}
// Add new members
for memberIdentity in allMembers {
guard !currentMembers.contains(memberIdentity),
!memberIdentity.elementsEqual(self.myIdentityStore.identity) else {
continue
}
guard let contact = self.entityManager.entityFetcher.contactEntity(for: memberIdentity) else {
let isIdentityRevoked = fetchedIdentities.contains { contactState in
if case .revokedOrInvalid(memberIdentity) = contactState {
return true
}
else if case .blocked(memberIdentity) = contactState {
return true
}
return false
}
if isIdentityRevoked {
// Do nothing because the contact never existed or was revoked or blocked
DDLogVerbose("Skip invalid, revoked or blocked contact")
continue
}
else {
throw GroupError.contactForMemberMissing
}
}
conversation.members?.insert(contact)
if let systemMessageDate {
self.postSystemMessage(
in: conversation,
member: contact,
type: .groupMemberAdd,
date: systemMessageDate
)
}
}
if groupIdentity.creator.rawValue.elementsEqual(self.myIdentityStore.identity) {
// Check is note group or not anymore
if allMembers.count == 1, allMembers.contains(self.myIdentityStore.identity) {
self.postSystemMessage(
in: conversation,
type: .startNoteGroupInfo,
arg: nil,
date: Date()
)
}
else if !groupNewCreated, allMembers.count > 1, currentMembers.isEmpty {
self.postSystemMessage(
in: conversation,
type: .endNoteGroupInfo,
arg: nil,
date: Date()
)
}
}
let lastSyncRequestSince = Date(timeIntervalSinceNow: TimeInterval(-kGroupSyncRequestInterval))
let lastSyncRequest = self.entityManager.entityFetcher.lastGroupSyncRequestEntity(
for: groupIdentity,
since: lastSyncRequestSince
)
let group = Group(
myIdentityStore: self.myIdentityStore,
userSettings: self.userSettings,
pushSettingManager: PushSettingManager(),
groupEntity: groupEntity,
conversation: conversation,
lastSyncRequest: lastSyncRequest?.lastSyncRequest
)
self.refreshRejectedMessages(in: group)
return group
}
}
else {
if !groupIdentity.creator.rawValue.elementsEqual(myIdentityStore.identity) {
// I'm not member or creator of the group
return await entityManager.performSave {
if let groupEntity = self.entityManager.entityFetcher.groupEntity(
for: groupIdentity,
myIdentity: self.myIdentityStore.identity
) {
var addSystemMessage = false
if groupEntity.state != NSNumber(value: GroupEntity.GroupState.forcedLeft.rawValue) {
groupEntity.state = NSNumber(value: GroupEntity.GroupState.forcedLeft.rawValue)
addSystemMessage = true
}
if let conversation = self.entityManager.entityFetcher.conversationEntity(
for: groupIdentity,
myIdentity: self.myIdentityStore.identity
) {
if addSystemMessage, let systemMessageDate {
self.postSystemMessage(
in: conversation,
type: .groupSelfRemoved,
arg: nil,
date: systemMessageDate
)
}
let lastSyncRequestSince =
Date(timeIntervalSinceNow: TimeInterval(-kGroupSyncRequestInterval))
let lastSyncRequest = self.entityManager.entityFetcher.lastGroupSyncRequestEntity(
for: groupIdentity,
since: lastSyncRequestSince
)
let group = Group(
myIdentityStore: self.myIdentityStore,
userSettings: self.userSettings,
pushSettingManager: PushSettingManager(),
groupEntity: groupEntity,
conversation: conversation,
lastSyncRequest: lastSyncRequest?.lastSyncRequest
)
self.refreshRejectedMessages(in: group)
return group
}
else {
DDLogWarn("Conversation entity for \(groupIdentity) not found")
}
}
else {
DDLogWarn("Group entity for \(groupIdentity) not found")
}
return nil
}
}
else {
DDLogNotice("I'm creator of the group \(groupIdentity) but not member")
}
return nil
}
}
/// Objective-C bridge
public func createOrUpdateDBObjc(
groupID: Data,
creator: String,
members: Set<String>,
systemMessageDate: Date?,
sourceCaller: SourceCaller
) async throws {
try await createOrUpdateDB(
for: GroupIdentity(id: groupID, creator: ThreemaIdentity(creator)),
members: members,
systemMessageDate: systemMessageDate,
sourceCaller: sourceCaller
)
}
/// Individually fetches the contacts with the listed identities from the database or requests them individually
/// from the directory server. This function will ignore the block unknown setting, because it should add all
/// contacts to member list.
/// Use `fetchContacts` to fetch multiple contacts
/// - Parameter identities: identities to fetch from the database or directory server
/// - Returns: The fetched contact or an error
private func fetchContacts(for identities: [String]) async throws -> [FetchedContactOrError] {
var fetchedResults = [FetchedContactOrError]()
let prefetchedIdentities = try await prefetchIdentities(for: identities)
for identity in prefetchedIdentities {
await fetchedResults.append(
withCheckedContinuation { continuation in
self.contactStore.fetchPublicKey(
for: identity,
acquaintanceLevel: .groupOrDeleted,
entityManager: self.entityManager,
ignoreBlockUnknown: true
) { _ in
guard self.entityManager.entityFetcher.contactEntity(for: identity) != nil else {
continuation.resume(returning: .localNotFound)
return
}
continuation.resume(returning: .added)
} onError: { error in
DDLogError("Error fetch public key")
if let nsError = error as? NSError, nsError.domain == NSURLErrorDomain,
nsError.code == 404 {
continuation.resume(returning: .revokedOrInvalid(identity))
}
else if let nsError = error as? NSError,
nsError.code == ThreemaProtocolError.blockUnknownContact.rawValue {
continuation.resume(returning: .blocked(identity))
}
else {
continuation.resume(returning: .error)
}
}
}
)
}
return fetchedResults
}
private func prefetchIdentities(for identities: [String]) async throws -> Set<String> {
try await withCheckedThrowingContinuation { continuation in
self.contactStore.prefetchIdentityInfo(Set(identities)) {
continuation.resume(returning: Set(identities))
} onError: { error in
continuation.resume(throwing: error)
}
}
}
// MARK: - Set name
public func setName(
groupID: Data,
creator: String,
name: String?,
systemMessageDate: Date,
send: Bool
) async throws {
guard let grp = getGroup(groupID, creator: creator) else {
throw GroupError.groupNotFound
}
return try await setName(group: grp, name: name, systemMessageDate: systemMessageDate, send: send)
}
/// Update group name in DB. If I'm the creator send group rename message (`GroupRenameMessage`) to members.
///
/// - Parameters:
/// - group: Group to update name
/// - name: New name of the group
/// - systemMessageDate: Date for new system message
/// - send: Send group rename messages if I'm the creator?
public func setName(
group: Group,
name: String?,
systemMessageDate: Date,
send: Bool
) async throws {
guard group.name != name else {
// Name didn't change
return
}
await entityManager.performSave {
guard let conversation = self.entityManager.entityFetcher.conversationEntity(
for: group.groupIdentity,
myIdentity: self.myIdentityStore.identity
)
else {
return
}
conversation.groupName = name
self.postSystemMessage(
in: conversation,
type: .renameGroup,
arg: name?.data(using: .utf8),
date: systemMessageDate
)
}
if send, group.isOwnGroup {
let task = createGroupRenameTask(for: group, to: group.allActiveMemberIdentitiesWithoutCreator)
try await add(task: task)
}
}
/// Objective-C bridge
public func setNameObjc(
groupID: Data,
creator: String,
name: String?,
systemMessageDate: Date,
send: Bool
) async throws {
try await setName(groupID: groupID, creator: creator, name: name, systemMessageDate: systemMessageDate)
}
// MARK: - Set photo
public func setPhoto(
groupID: Data,
creator: String,
imageData: Data,
sentDate: Date,
send: Bool
) async throws {
guard let grp = getGroup(groupID, creator: creator) else {
throw GroupError.groupNotFound
}
return try await setPhoto(group: grp, imageData: imageData, sentDate: sentDate, send: send)
}
/// Update group photo and upload photo and send group set photo message (`GroupSetPhotoMessage`) to members, if I'm
/// the creator.
///
/// - Parameters:
/// - group: Group to update photo
/// - imageData: Photo raw image data, supporting UIImage
/// - sentDate: Sent date of set photo message
/// - send: If `True` and I'm the creator: upload photo and send group set photo messages
/// - Throws: GroupError.groupConversationNotFound, GroupError.decodingFailed
/// GroupError.notCreator, GroupError.photoUploadFailed, GroupError.blobIDOrKeyMissing
public func setPhoto(
group: Group,
imageData: Data,
sentDate: Date,
send: Bool
) async throws {
let (conversationObjectID, conversationGroupImageSetDate, conversationGroupImageData) = await entityManager
.perform {
let conversation = self.entityManager.entityFetcher.conversationEntity(
for: group.groupIdentity,
myIdentity: self.myIdentityStore.identity
)
return (conversation?.objectID, conversation?.groupImageSetDate, conversation?.groupImage?.data)
}
guard let conversationObjectID else {
throw GroupError.groupConversationNotFound
}
var imageDataSend: Data?
// Check if this message is older than the last set date. This ensures that we're using
// the latest image in case multiple images arrive for the same conversation in short succession.
// Must do the check here (main thread) to avoid race condition.
if let imageSetDate = conversationGroupImageSetDate,
imageSetDate.compare(sentDate) == .orderedDescending {
DDLogInfo("Ignoring older group set photo message")
imageDataSend = conversationGroupImageData
}
else if let image = UIImage(data: imageData) {
do {
try entityManager.performAndWaitSave {
guard let conversation = self.entityManager.entityFetcher
.existingObject(with: conversationObjectID) as? ConversationEntity else {
throw GroupError.groupConversationNotFound
}
let imageEntity: ImageDataEntity
func setImageDataEntity(_ imageDataEntity: ImageDataEntity) {
imageDataEntity.data = imageData
imageDataEntity.width = Int16(image.size.width)
imageDataEntity.height = Int16(image.size.height)
}
if let groupImageEntity = conversation.groupImage {
guard groupImageEntity.data != imageData else {
return
}
setImageDataEntity(groupImageEntity)
imageEntity = groupImageEntity
}
else {
let newImageEntity = self.entityManager.entityCreator.imageDataEntity(
data: imageData,
size: image.size
)
imageEntity = newImageEntity
}
conversation.groupImageSetDate = sentDate
conversation.groupImage = imageEntity
self.postSystemMessage(
in: conversation,
type: .groupProfilePictureChanged,
arg: nil,
date: Date()
)
}
}
catch {
throw error
}
imageDataSend = imageData
}
else {
throw GroupError.decodingFailed
}
if send, group.isOwnGroup,
let imageDataSend {
try await sendPhoto(
to: group,
imageData: imageDataSend,
toMembers: group.allActiveMemberIdentitiesWithoutCreator
)
}
}
/// Objective-C bridge
public func setPhotoObjc(
groupID: Data,
creator: String,
imageData: Data,
sentDate: Date,
send: Bool
) async throws {
try await setPhoto(groupID: groupID, creator: creator, imageData: imageData, sentDate: sentDate, send: send)
}
// MARK: - Delete photo
/// Delete group photo and send group delete photo message (`GroupDeletePhotoMessage`) to members, if I'm the
/// creator.
///
/// - Parameters:
/// - groupID: ID (8 bytes) of the group, unique with creator
/// - creator: Creator (identity) of the group, unique with ID
/// - sentDate: Sent date of delete photo message
/// - send: True send group delete photo message if I'm the creator
/// - Throws: GroupError.groupNotFound, GroupError.groupConversationNotFound
public func deletePhoto(
groupID: Data,
creator: String,
sentDate: Date,
send: Bool
) async throws {
guard let grp = getGroup(groupID, creator: creator) else {
throw GroupError.groupNotFound
}
try await entityManager.performSave {
guard let conversation = self.entityManager.entityFetcher.conversationEntity(
for: grp.groupIdentity,
myIdentity: self.myIdentityStore.identity
) else {
throw GroupError.groupConversationNotFound
}
conversation.groupImageSetDate = sentDate
if let groupImage = conversation.groupImage {
self.entityManager.entityDestroyer.delete(imageDataEntity: groupImage)
conversation.groupImage = nil
self.postSystemMessage(
in: conversation,
type: .groupProfilePictureChanged,
arg: nil,
date: sentDate
)
}
}
if send, grp.isOwnGroup {
let task = createDeletePhotoTask(for: grp, to: grp.allActiveMemberIdentitiesWithoutCreator)
try await add(task: task)
}
}
/// Objective-C bridge
public func deletePhotoObjc(
groupID: Data,
creator: String,
sentDate: Date,
send: Bool
) async throws {
try await deletePhoto(groupID: groupID, creator: creator, sentDate: sentDate, send: send)
}
// MARK: - Leave
/// Send group leave and leave the group, admin of the group may not allowed to leave the group.
///
/// - Parameters:
/// - groupID: ID (8 bytes) of the group, unique with creator
/// - creator: Creator (identity) of the group, unique with ID
/// - toMembers: Receivers of the group leave message, if nil send to all members of existing group
/// - systemMessageDate: Date for new system message
@objc public func leave(groupID: Data, creator: String, toMembers: [String]?, systemMessageDate: Date) {
guard creator != myIdentityStore.identity else {
DDLogWarn("Group creator can't leave the group")
return
}
// Send leave group message even I'm left the group already or the group not exists
var currentMembers = [String]()
var hiddenContacts = [String]()
entityManager.performAndWait {
if let conversation = self
.getConversation(for: GroupIdentity(id: groupID, creator: ThreemaIdentity(creator))) {
currentMembers = conversation.unwrappedMembers.map(\.identity)
hiddenContacts = conversation.unwrappedMembers.filter(\.isHidden).map(\.identity)
}
}
var sendToMembers = toMembers ?? currentMembers
if sendToMembers.isEmpty {
// Add Me as receiver to reflect group leave message
sendToMembers.append(myIdentityStore.identity)
}
let task = TaskDefinitionSendGroupLeaveMessage(sendContactProfilePicture: false)
task.groupID = groupID
task.groupCreatorIdentity = creator
task.fromMember = myIdentityStore.identity
task.toMembers = sendToMembers
task.hiddenContacts = hiddenContacts
taskManager.add(taskDefinition: task)
if let group = getGroup(groupID, creator: creator) {
guard group.state != .left, group.state != .forcedLeft else {
DDLogWarn("I can't left the group, I'm not member of this group anymore")
return
}
}
leaveDB(
groupID: groupID,
creator: creator,
member: myIdentityStore.identity,
systemMessageDate: systemMessageDate
)
}
/// Remove member from group in DB.
///
/// - Parameters:
/// - groupID: ID (8 bytes) of the group, unique with creator
/// - creator: Creator (identity) of the group, unique with ID
/// - member: Member who left the group
/// - systemMessageDate: Date for new system message
public func leaveDB(groupID: Data, creator: String, member: String, systemMessageDate: Date) {
entityManager.performAndWait {
guard let grp = self.getGroup(groupID, creator: creator) else {
DDLogWarn("Group not found")
return
}
guard let groupEntity = self.entityManager.entityFetcher.groupEntity(
for: grp.groupIdentity,
myIdentity: self.myIdentityStore.identity
)
else {
DDLogWarn("Group entity not found")
return
}
guard let conversation = self
.getConversation(for: GroupIdentity(id: groupID, creator: ThreemaIdentity(creator))) else {
DDLogWarn("Conversation not found")
return
}
DDLogInfo("Member \(member) left the group \(grp.groupID.hexString) \(creator)")
if let contact = conversation.unwrappedMembers.first(where: { contact -> Bool in
contact.identity.elementsEqual(member)
}) {
self.entityManager.performAndWaitSave {
_ = conversation.members?.remove(contact)
}
self.postSystemMessage(
in: conversation,
member: contact,
type: .groupMemberLeave,
date: systemMessageDate
)
if creator.elementsEqual(self.myIdentityStore.identity),
grp.isNoteGroup {
self.postSystemMessage(
in: conversation,
type: .startNoteGroupInfo,
arg: nil,
date: Date()
)
}
else if creator == member, !grp.isNoteGroup {
self.postSystemMessage(
in: conversation,
type: .groupCreatorLeft,
arg: nil,
date: Date()
)
}
}
else if member.elementsEqual(self.myIdentityStore.identity), !grp.didLeave {
self.entityManager.performAndWaitSave {
groupEntity.state = NSNumber(value: GroupEntity.GroupState.left.rawValue)
if !(conversation.groupMyIdentity?.elementsEqual(self.myIdentityStore.identity) ?? false) {
conversation.groupMyIdentity = self.myIdentityStore.identity
}
}
self.postSystemMessage(
in: conversation,
type: .groupSelfLeft,
arg: nil,
date: systemMessageDate
)
}
self.refreshRejectedMessages(in: grp)
}
}
/// Send empty member list to every group member to dissolve the group and i left the group.
///
/// - Parameters:
/// - groupID: Group ID to suspend (I'm the group creator)
/// - identities: Identities to send dissolve to, if `nil` all group members get the dissolve message
@objc public func dissolve(
groupID: Data,
to identities: Set<String>?
) {
entityManager.performAndWait {
if let group = self.getGroup(groupID, creator: self.myIdentityStore.identity) {
guard group.isSelfCreator else {
return
}
// Add task to kick all active members (and reflect left group), and left the group
let task = TaskDefinitionGroupDissolve(group: group)
if let identities {
task.toMembers = Array(identities)
}
else {
task.toMembers = group.allActiveMemberIdentitiesWithoutCreator
}
self.taskManager.add(taskDefinition: task)
if group.state == .active {
// I leave the group
self.leaveDB(
groupID: group.groupID,
creator: group.groupCreatorIdentity,
member: group.groupCreatorIdentity,
systemMessageDate: Date()
)
}
}
else {
// Group not found (means conversation was deleted), kick identities except me if I'm group creator and
// has left the group
guard let groupEntity = self.entityManager.entityFetcher.groupEntity(
for: groupID
),
groupEntity.didLeave,
let identities
else {
return
}
let removeMembers = identities.filter { $0 != self.myIdentityStore.identity }
if !removeMembers.isEmpty {
self.taskManager.add(
taskDefinition: TaskDefinitionSendGroupCreateMessage(
groupID: groupID,
groupCreatorIdentity: self.myIdentityStore.identity,
groupName: nil,
allGroupMembers: nil,
isNoteGroup: nil,
to: [],
removed: Array(removeMembers),
members: Set<String>(),
sendContactProfilePicture: false
)
)
}
}
}
}
/// Send empty member list to the receivers, if I'm the group creator
///
/// - Parameters:
/// - groupID: Group Identity
/// - identities: Identities to send empty member list to
public func sendEmptyMemberList(
groupIdentity: GroupIdentity,
to identities: Set<ThreemaIdentity>
) {
guard let group = getGroup(groupIdentity.id, creator: groupIdentity.creator.rawValue),
group.isSelfCreator else {
let errorMessage =
"Could not send empty member list because the group does not exist or I am not the creator of the group (id: \(groupIdentity.id.hexString), creator: \(groupIdentity.creator.rawValue))"
assertionFailure(errorMessage)
DDLogError("\(errorMessage)")
return
}
taskManager.add(
taskDefinition: TaskDefinitionSendGroupCreateMessage(
groupID: groupIdentity.id,
groupCreatorIdentity: groupIdentity.creator.rawValue,
groupName: nil,
allGroupMembers: nil,
isNoteGroup: nil,
to: Array(identities.map(\.rawValue)),
removed: nil,
members: Set<String>(),
sendContactProfilePicture: false
)
)
}
// MARK: - Sync
/// Sync group information to identities.
///
/// Send group create, rename and set photo message to each identity that is a member. Send a group create to
/// non-members with an empty
/// members list.
///
/// - Parameters:
/// - group: Group to sync
/// - identities: Identities to send sync to, if `nil` all group members get the sync messages
/// - withoutCreateMessage: Should the create message also be sent to group members?
/// (This is useful to send all group information to recently added members.)
public func sync(
group: Group,
to identities: Set<String>?,
withoutCreateMessage: Bool
) async throws {
// Ensure that we are the creator
guard group.isOwnGroup else {
throw GroupError.notCreator
}
guard let identities else {
// Sync to all members
try await sync(
group: group,
to: group.allActiveMemberIdentitiesWithoutCreator,
withoutCreateMessage: withoutCreateMessage
)
return
}
// If we have a list of receivers filter between members and non-members.
var activeMembers = [String]()
var removedMembers = [String]()
for identity in identities {
if group.allMemberIdentities.contains(identity) {
activeMembers.append(identity)
}
else {
removedMembers.append(identity)
}
}
try await sync(group: group, to: activeMembers, withoutCreateMessage: withoutCreateMessage)
try await syncToRemovedMembers(group: group, to: removedMembers)
}
public func syncObjc(
group: Group,
to identities: Set<String>?,
withoutCreateMessage: Bool
) async throws {
try await sync(group: group, to: identities, withoutCreateMessage: withoutCreateMessage)
}
/// Send sync request for this group, is not already requested in the last 7 days (see `kGroupSyncRequestInterval`).
///
/// - Parameters:
/// - groupID: ID 8 Bytes
/// - creator: Creator of group
/// - force: If the minimum time interval should be ignored
@objc public func sendSyncRequest(groupID: Data, creator: String, force: Bool) {
let lastSyncRequestSince = Date(timeIntervalSinceNow: TimeInterval(-kGroupSyncRequestInterval))
let groupIdentity = GroupIdentity(id: groupID, creator: ThreemaIdentity(creator))
guard entityManager.entityFetcher.lastGroupSyncRequestEntity(
for: groupIdentity,
since: lastSyncRequestSince
) == nil || force else {
DDLogInfo(
"Sync for Group ID \(groupID.hexString) (creator \(creator)) already requested in the last \(kGroupSyncRequestInterval) s."
)
return
}
DDLogWarn("Group ID \(groupID.hexString) (creator \(creator)) not found. Requesting sync from creator.")
// Fetch creator first, contact could be missing
contactStore.fetchPublicKey(
for: creator,
acquaintanceLevel: .groupOrDeleted,
entityManager: entityManager,
ignoreBlockUnknown: false
) { _ in
if self.entityManager.entityFetcher.contactEntity(for: creator) != nil {
self.recordSendSyncRequest(GroupIdentity(id: groupID, creator: ThreemaIdentity(creator)))
self.sendGroupSyncRequest(groupID, creator)
}
else {
DDLogError("Could not send group request sync, because of missing group creator \(creator) contact")
}
} onError: { error in
if let error = error as? NSError {
DDLogError("Could not fetch public key for \(creator); Error: \(error.description) \(error.code) ")
}
else {
DDLogError("Could not fetch public key for \(creator)")
}
}
}
/// Start periodic group sync if needed
///
/// Sync tasks are guaranteed to be enqueued when this function returns, except of a potential set photo message
///
/// - Parameter group: Group conversation to sync
public func periodicSyncIfNeeded(for group: Group) {
// Check if we are the creator
guard group.isOwnGroup else {
return
}
// Check if sync is needed
if let lastPeriodicSync = group.lastPeriodicSync {
let timeSinceLastSync = lastPeriodicSync.timeIntervalSinceNow
if timeSinceLastSync > TimeInterval(-kGroupPeriodicSyncInterval) {
// Last sync is shorter than `kGroupPeriodicSyncInterval` in the past
return
}
}
let toMembers = group.allActiveMemberIdentitiesWithoutCreator
// Do these step synchronously to ensure they are enqueued before a potential outgoing
// message leading to this call.
// We don't care if the task execution fails eventually as the likelihood is low.
guard let conversation =
getConversation(for: GroupIdentity(id: group.groupID, creator: group.groupIdentity.creator))
else {
DDLogWarn("Coversation not found")
return
}
// 5. Send a group-setup message with the current group members, ...
let createTask = createGroupCreateSyncTask(for: group, conversation: conversation, to: toMembers)
taskManager.add(taskDefinition: createTask)
// ...followed by a group-name message to the sender.
let sendNameTask = createGroupRenameTask(for: group, to: toMembers)
taskManager.add(taskDefinition: sendNameTask)
// 7. If the group has no profile picture, send a `delete-profile-picture` group control message to the sender.
if group.old_ProfilePicture == nil {
let deletePhotoTask = createDeletePhotoTask(for: group, to: toMembers)
taskManager.add(taskDefinition: deletePhotoTask)
}
entityManager.performAndWaitSave {
if let groupEntity = self.entityManager.entityFetcher.groupEntity(
for: group.groupIdentity,
myIdentity: self.myIdentityStore.identity
) {
groupEntity.lastPeriodicSync = Date()
}
}
// As sending a profile picture might take a while to upload we do it asynchronously and
// don't guarantee it to be sent out before the a group message is sent
// 6. If the group has a profile picture, send a `set-profile-picture` group control message to the sender.
if let profilePicture = group.old_ProfilePicture {
Task {
do {
try await self.sendPhoto(to: group, imageData: profilePicture, toMembers: toMembers)
}
catch {
// Note: This might never be called if the task was persisted at some point in
// the meantime
DDLogError("Periodic group sync photo sending failed: \(error)")
// Reset last periodic sync date if photo sending failed
await self.entityManager.performSave {
if let groupEntity = self.entityManager.entityFetcher.groupEntity(
for: group.groupIdentity,
myIdentity: self.myIdentityStore.identity
) {
groupEntity.lastPeriodicSync = nil
}
}
}
}
}
}
// MARK: - Get group / conversation
public func getConversation(for groupIdentity: GroupIdentity) -> ConversationEntity? {
entityManager.entityFetcher.conversationEntity(for: groupIdentity, myIdentity: myIdentityStore.identity)
}
/// Loads group, conversation and LastGroupSyncRequest from DB.
///
/// - Parameters:
/// - groupID: ID 8 Bytes
/// - creator: Creator of group
/// - Returns: The group or nil
@objc public func getGroup(_ groupID: Data, creator: String) -> Group? {
var group: Group?
entityManager.performAndWait {
let groupIdentity = GroupIdentity(id: groupID, creator: ThreemaIdentity(creator))
guard let conversation = self.entityManager.entityFetcher.conversationEntity(
for: groupIdentity, myIdentity: self.myIdentityStore.identity
) else {
return
}
guard let groupEntity = self.entityManager.entityFetcher.groupEntity(for: conversation) else {
return
}
group = self.getGroup(groupEntity: groupEntity, conversation: conversation)
}
return group
}
/// Loads group for conversation.
///
/// - Parameter conversation: Conversation for group
/// - Returns: The group or nil
@objc public func getGroup(conversation: ConversationEntity) -> Group? {
var group: Group?
entityManager.performAndWait {
guard let groupEntity = self.entityManager.entityFetcher.groupEntity(for: conversation) else {
return
}
group = self.getGroup(groupEntity: groupEntity, conversation: conversation)
}
return group
}
public func getAllActiveGroups() async -> [Group] {
await entityManager.perform {
let allActiveGroupEntities = self.entityManager.entityFetcher.activeGroupEntities() ?? []
return allActiveGroupEntities.compactMap { groupEntity in
// Because the entity fetcher for group conversations needs a creator identity we set them to our own
// identity. If our ID changed in the meantime (e.g. through a restore were the data is restored, but a
// new ID created) we will still fetch the correct group, that we're not really part of anymore, but
// might be still marked as active...
guard let creatorIdentity = groupEntity.groupCreator ?? self.myIdentityStore.identity else {
return nil
}
let groupIdentity = GroupIdentity(id: groupEntity.groupID, creator: ThreemaIdentity(creatorIdentity))
guard let conversation = self.entityManager.entityFetcher.conversationEntity(
for: groupIdentity, myIdentity: self.myIdentityStore.identity
) else {
return nil
}
return self.getGroup(groupEntity: groupEntity, conversation: conversation)
}
}
}
private func getGroup(groupEntity: GroupEntity, conversation: ConversationEntity) -> Group {
let creator: String = groupEntity.groupCreator ?? myIdentityStore.identity
let groupIdentity = GroupIdentity(id: groupEntity.groupID, creator: ThreemaIdentity(creator))
let lastSyncRequestSince = Date(timeIntervalSinceNow: TimeInterval(-kGroupSyncRequestInterval))
let lastSyncRequest = entityManager.entityFetcher.lastGroupSyncRequestEntity(
for: groupIdentity,
since: lastSyncRequestSince
)
return Group(
myIdentityStore: myIdentityStore,
userSettings: userSettings,
pushSettingManager: PushSettingManager(),
groupEntity: groupEntity,
conversation: conversation,
lastSyncRequest: lastSyncRequest?.lastSyncRequest
)
}
/// Get all members of existing group except me. It will remove the hidden flag for all members.
///
/// - Parameters:
/// - groupID: ID 8 Bytes
/// - creator: Creator of group
/// - Returns: Members of the group
func getGroupMembersForClone(_ groupID: Data, creator: String) -> Set<ContactEntity>? {
guard let conversation =
getConversation(for: GroupIdentity(id: groupID, creator: ThreemaIdentity(creator))) else {
DDLogError("Group conversation not found")
return nil
}
// Mark all contacts as visible
removeContactHiddenFlags(for: conversation)
return conversation.unwrappedMembers.filter { member -> Bool in
!member.identity.elementsEqual(myIdentityStore.identity)
}
}
// MARK: - Private functions
private func postSystemMessage(
in conversation: ConversationEntity,
member: ContactEntity,
type: SystemMessageEntity.SystemMessageEntityType,
date: Date
) {
postSystemMessage(in: conversation, type: type, arg: Data(member.displayName.utf8), date: date)
}
private func postSystemMessage(
in conversation: ConversationEntity,
type: SystemMessageEntity.SystemMessageEntityType,
arg: Data?,
date: Date
) {
entityManager.performAndWaitSave {
// Insert system message to document this change
let sysMsg = self.entityManager.entityCreator.systemMessageEntity(
for: type,
in: conversation
)
sysMsg.arg = arg
sysMsg.date = date
sysMsg.remoteSentDate = date
}
}
// MARK: Sync
private func sync(
group: Group,
to toMembers: [String],
withoutCreateMessage: Bool
) async throws {
/// Sync group if `toMembers` empty to reflect note groups if multi device activated.
guard !(toMembers.isEmpty && !userSettings.enableMultiDevice) else {
return
}
// Prepare sync tasks
func runGroupCreateTask() async throws {
guard !withoutCreateMessage else {
return
}
guard let conversation =
getConversation(for: GroupIdentity(id: group.groupID, creator: group.groupIdentity.creator))
else {
throw GroupError.groupConversationNotFound
}
// 5. Send a group-setup message with the current group members, ...
let task = createGroupCreateSyncTask(for: group, conversation: conversation, to: toMembers)
try await add(task: task)
}
// ...followed by a group-name message to the sender.
func runGroupNameTask() async throws {
let task = createGroupRenameTask(for: group, to: toMembers)
try await add(task: task)
}
func runGroupPhotoTask() async throws {
var profilePicture: Data?
await entityManager.perform {
profilePicture = group.old_ProfilePicture
}
guard let data = profilePicture else {
// 7. If the group has no profile picture, send a `delete-profile-picture` group
// control message to the sender.
let task = createDeletePhotoTask(for: group, to: toMembers)
try await add(task: task)
return
}
// 6. If the group has a profile picture, send a `set-profile-picture` group control
// message to the sender.
try await sendPhoto(to: group, imageData: data, toMembers: toMembers)
}
try await runGroupCreateTask()
try await runGroupNameTask()
try await runGroupPhotoTask()
}
private func syncToRemovedMembers(
group: Group,
to removedMembers: [String]
) async throws {
guard !removedMembers.isEmpty else {
return
}
guard let conversation =
getConversation(for: GroupIdentity(id: group.groupID, creator: group.groupIdentity.creator))
else {
return
}
let members = Set(conversation.unwrappedMembers.map(\.identity))
let emptyCreateTask = TaskDefinitionSendGroupCreateMessage(
group: group,
to: [],
removed: removedMembers,
members: members
)
try await add(task: emptyCreateTask)
}
private func sendPhoto(
to group: Group,
imageData: Data,
toMembers: [String]
) async throws {
guard group.isOwnGroup else {
throw GroupError.notCreator
}
// Only capture blob data inside the callback — no CoreData access.
let (blobID, encryptionKey): (Data, Data) = try await withCheckedThrowingContinuation { continuation in
groupPhotoSender().start(
withImageData: imageData,
isNoteGroup: group.isNoteGroup
) { blobID, encryptionKey in
guard let blobID, let encryptionKey else {
continuation.resume(throwing: GroupError.blobIDOrKeyMissing)
return
}
continuation.resume(returning: (blobID, encryptionKey))
} onError: { _ in
continuation.resume(throwing: GroupError.photoUploadFailed)
}
}
let task = TaskDefinitionSendGroupSetPhotoMessage(
group: group,
from: myIdentityStore.identity,
to: toMembers,
size: UInt32(imageData.count),
blobID: blobID,
encryptionKey: encryptionKey
)
try await add(task: task)
}
private func createGroupCreateSyncTask(
for group: Group,
conversation: ConversationEntity,
to toMembers: [String]
) -> TaskDefinitionSendGroupCreateMessage {
let members = entityManager.performAndWait {
Set(conversation.unwrappedMembers.map(\.identity))
}
return TaskDefinitionSendGroupCreateMessage(
group: group,
to: toMembers,
members: members
)
}
private func createGroupRenameTask(
for group: Group,
to toMembers: [String]
) -> TaskDefinitionSendGroupRenameMessage {
TaskDefinitionSendGroupRenameMessage(
group: group,
from: myIdentityStore.identity,
to: toMembers,
newName: group.name
)
}
private func createDeletePhotoTask(
for group: Group,
to toMembers: [String]
) -> TaskDefinitionSendGroupDeletePhotoMessage {
TaskDefinitionSendGroupDeletePhotoMessage(
group: group,
from: myIdentityStore.identity,
to: toMembers,
sendContactProfilePicture: false
)
}
private func add(task: TaskDefinitionProtocol) async throws {
// swiftformat:disable:next all
return try await withCheckedThrowingContinuation { continuation in
taskManager.add(taskDefinition: task) { _, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume()
}
}
}
private func recordSendSyncRequest(_ groupIdentity: GroupIdentity) {
entityManager.performAndWaitSave {
// Record this sync request
_ = self.entityManager.entityCreator.lastGroupSyncRequestEntity(
groupIdentity: groupIdentity,
lastSyncRequest: .now
)
}
}
/// Caution: Use this only for testing!
public func deleteAllSyncRequestRecords() {
if let entities = entityManager.entityFetcher.lastGroupSyncRequestEntities() {
for entity in entities {
entityManager.entityDestroyer.delete(lastGroupSyncRequestEntity: entity)
}
}
}
private func sendGroupSyncRequest(_ groupID: Data, _ creator: String) {
let msg = GroupRequestSyncMessage()
msg.groupID = groupID
msg.groupCreator = creator
msg.toIdentity = creator
let task = TaskDefinitionSendAbstractMessage(message: msg)
taskManager.add(taskDefinition: task)
}
private func removeContactHiddenFlags(for conversation: ConversationEntity) {
// Get all hidden contacts and mark them as visible
let hiddenMembers = conversation.unwrappedMembers.filter { member -> Bool in
!member.identity.elementsEqual(myIdentityStore.identity)
&& member.isHidden
}
if !hiddenMembers.isEmpty {
entityManager.performAndWaitSave {
for member in hiddenMembers {
member.isHidden = false
}
}
}
}
// MARK: Rejected Messages Refresh Steps
/// Implementation of `Rejected Messages Refresh Steps`
///
/// From the protocol documentation:
/// > [...] run every time the group members are being updated
///
/// - Parameter group: Group to run Rejected Messages Refresh Steps on
private func refreshRejectedMessages(in group: Group) {
// 2. If `group` is marked as _left_:
if group.state == .left || group.state == .forcedLeft {
resetAllRejectedMessages(in: group)
}
// 3. If `group` is not marked as _left_:
else {
updateAllRejectedMessages(in: group)
}
}
/// Don't call this directly. Use `refreshRejectedMessages(in:)` instead
private func resetAllRejectedMessages(in group: Group) {
// 1. For each `message` of `group` that has a _re-send requested_ mark,
// remove the mark and the list of receivers requiring a re-send.
entityManager.performAndWaitSave {
let messageFetcher = MessageFetcher(for: group.conversation, with: self.entityManager)
let allRejectedMessages = messageFetcher.rejectedGroupMessages()
for rejectedMessage in allRejectedMessages {
rejectedMessage.rejectedBy = Set()
// We only reset `sendFailed` if the message was sent successfully before. As `sendFailed` might not
// have been set because of rejections. In theory this should never happen as an unsent message should
// never have been rejected.
if rejectedMessage.sent.boolValue {
rejectedMessage.sendFailed = false
}
}
}
}
/// Don't call this directly. Use `refreshRejectedMessages(in:)` instead
private func updateAllRejectedMessages(in group: Group) {
entityManager.performAndWaitSave {
// 1. Let `members` be the current list of members for `group`.
let members = group.members.map(\.identity)
// 2. For each `message` of `group` that has a _re-send requested_ mark:
let messageFetcher = MessageFetcher(for: group.conversation, with: self.entityManager)
let allRejectedMessages = messageFetcher.rejectedGroupMessages()
for rejectedMessage in allRejectedMessages {
self.updateRejectedMessage(rejectedMessage, with: members)
}
}
}
private func updateRejectedMessage(_ rejectedMessage: BaseMessageEntity, with groupMembers: [ThreemaIdentity]) {
// 1. Let `receivers` be the list of receivers requiring a re-send for
// `message`.
guard let rejectedByContactsList = rejectedMessage.rejectedBy?.map({
ThreemaIdentity($0.identity)
}) else {
return
}
let rejectedByContacts = Set(rejectedByContactsList)
// 2. Remove all entries from `receivers` that are not present in
// `members`.
let contactsToRemove = rejectedByContacts.subtracting(groupMembers)
for contactToRemove in contactsToRemove {
guard let contact = entityManager.entityFetcher.contactEntity(for: contactToRemove.rawValue) else {
continue
}
rejectedMessage.removeFromRejectedBy(contact)
}
// 3. If `receivers` is now empty, remove the _re-send requested_ mark on
// `message`.
// We only reset `sendFailed` if the message was sent successfully before. As `sendFailed` might not
// have been set because of rejections. In theory this should never happen as an unsent message should
// never have been rejected.
if rejectedMessage.rejectedBy?.isEmpty ?? true, rejectedMessage.sent.boolValue {
rejectedMessage.sendFailed = false
}
}
}
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-11-04)
¤
*© Formatika GbR, Deutschland
|
|
2026-11-01
|
|
|
|
|
Neuigkeiten |
| Aktuelles |
| Motto des Tages |
|
Open Source Software |
|
|
|
Jenseits des Üblichen ....
|
|
Besucherstatistik |
|
|
| Statistik |
| #Sources=1104850 |
| #Domains=416977 |
|
|