import CocoaLumberjackSwift
import CoreData
import FileUtility
import Foundation
public final class EntityDestroyer: NSObject {
enum EntityDestroyerError: Error {
case entityForDescriptionNotFound
}
/// Relative to app data (group container) path.
static let externalDataBinPath = "_EXTERNAL_DATA_BIN"
private let objCnx: ThreemaManagedObjectContext
private let isRemoteSecretEnabled: Bool
required init(
managedObjectContext: ThreemaManagedObjectContext,
) {
self.objCnx = managedObjectContext
self.isRemoteSecretEnabled = managedObjectContext.usesAdditionallyEncryptedModel
}
// MARK: - Public methods
/// Delete media files of audio, file, image and video messages.
///
/// - Parameters:
/// - olderThan: All message older than that date will be deleted
/// - conversation: ConversationEntity
/// - Returns:
/// Count of deleted media files
public func deleteMedias(olderThan: Date?, for conversation: ConversationEntity? = nil) -> Int? {
var deletedObjects = 0
let mediaMessageTypes = [
AudioMessageEntity.self,
FileMessageEntity.self,
ImageMessageEntity.self,
VideoMessageEntity.self,
]
for mediaMessageType in mediaMessageTypes {
if let count = deleteMediasOf(
messageType: mediaMessageType,
olderThan: olderThan,
conversation: conversation
) {
deletedObjects += count
}
}
return deletedObjects
}
func deleteMediasOf(
messageType: (some BaseMessageEntity).Type,
olderThan: Date?,
conversation: ConversationEntity? = nil
) -> Int? {
guard messageType is AudioMessageEntity.Type ||
messageType is FileMessageEntity.Type ||
messageType is ImageMessageEntity.Type ||
messageType is VideoMessageEntity.Type
else {
return nil
}
do {
let mediaMetaInfo = try getMediaMetaInfo(messageType: messageType)
if let olderThan {
if let conversation {
mediaMetaInfo.fetchMessages.predicate = NSPredicate(
format: "conversation == %@ AND %K != nil AND date < %@",
conversation,
mediaMetaInfo.relationship,
olderThan as NSDate
)
}
else {
mediaMetaInfo.fetchMessages.predicate = NSPredicate(
format: "%K != nil AND date < %@",
mediaMetaInfo.relationship,
olderThan as NSDate
)
}
}
else {
if let conversation {
mediaMetaInfo.fetchMessages.predicate = NSPredicate(
format: "conversation == %@ AND %K != nil",
conversation,
mediaMetaInfo.relationship
)
}
else {
mediaMetaInfo.fetchMessages.predicate = NSPredicate(format: "%K != nil", mediaMetaInfo.relationship)
}
}
guard let messages = try objCnx.fetch(mediaMetaInfo.fetchMessages) as? [BaseMessageEntity],
!messages.isEmpty else {
return 0
}
try deleteMedias(of: messages, messageType: messageType)
return messages.count
}
catch let error as NSError {
DDLogError("Could not delete medias. \(error), \(error.userInfo)")
}
return 0
}
private func deleteMedias<T: BaseMessageEntity>(of messages: [BaseMessageEntity], messageType: T.Type) throws {
guard messageType is AudioMessageEntity.Type ||
messageType is FileMessageEntity.Type ||
messageType is ImageMessageEntity.Type ||
messageType is VideoMessageEntity.Type
else {
return
}
var deleteMediaIDs: [NSManagedObjectID] = []
var updateMessageIDs: [NSManagedObjectID] = []
let mediaMetaInfo = try getMediaMetaInfo(messageType: messageType)
for message in messages {
if let message = message as? T {
deleteMediaIDs
.append(contentsOf: message.objectIDs(forRelationshipNamed: mediaMetaInfo.relationship))
updateMessageIDs.append(message.objectID)
}
}
let deleteFilenames = getExternalFilenames(ofMessages: messages, includeThumbnail: false)
if !deleteMediaIDs.isEmpty {
var changes = [AnyHashable: [NSManagedObjectID]]()
// Delete media
var confirmedDeletedIDs: [NSManagedObjectID] = []
objCnx.propagatesDeletesAtEndOfEvent = true
for mediaID in deleteMediaIDs {
objCnx.performAndWait {
do {
let object = try self.objCnx.existingObject(with: mediaID)
if let message = object as? BaseMessageEntity {
nullifyConversationLastMessage(for: [message])
}
self.objCnx.delete(object)
try self.objCnx.save()
confirmedDeletedIDs.append(mediaID)
}
catch {
DDLogError("Could not delete file. Error: \(error); \(error.localizedDescription)")
}
}
}
changes[NSDeletedObjectIDsKey] = confirmedDeletedIDs
if !updateMessageIDs.isEmpty {
var updatedIDs: [NSManagedObjectID] = []
for updateID in updateMessageIDs {
if let updateMessage = try objCnx.existingObject(with: updateID) as? T {
objCnx.performAndWait {
var updated = false
// Update data reference to nil (if it failed to to be deleted when the object was
// deleted)
if updateMessage.value(forKey: mediaMetaInfo.relationship) != nil {
updateMessage.setValue(nil, forKey: mediaMetaInfo.relationship)
updated = true
}
// Update blobIDs to nil (to prevent downloading blob again)
if updateMessage.value(forKey: mediaMetaInfo.blobIDField) != nil {
updateMessage.setValue(nil, forKey: mediaMetaInfo.blobIDField)
updated = true
}
if updated {
do {
try self.objCnx.save()
updatedIDs.append(updateID)
}
catch let error as NSError {
DDLogError("Cloud not update message. \(error), \(error.userInfo)")
}
}
}
}
}
DatabaseContext.changed(objectIDs: Set(updatedIDs))
changes[NSUpdatedObjectsKey] = updatedIDs
}
deleteExternalFiles(list: deleteFilenames)
}
}
/// Delete content of given message, message metadata remains.
/// - Parameter message: Message to its content
public func deleteMessageContent(of message: BaseMessageEntity) throws {
guard message.typeSupportsRemoteDeletion else {
return
}
// Just delete media for message types like `AudioMessageEntity`, `FileMessageEntity`, `ImageMessageEntity` and
// `VideoMessageEntity`
try deleteMedias(of: [message], messageType: type(of: message))
// Remove all reactions
message.userack = 0
message.userackDate = nil
message.groupDeliveryReceipts = nil
if let message = message as? LocationMessageEntity {
message.latitude = 0
message.longitude = 0
message.accuracy = 0
message.poiAddress = nil
message.poiName = nil
}
else if let message = message as? TextMessageEntity {
message.text = ""
}
else if let message = message as? FileMessageEntity {
deleteThumbnail(for: message)
message.blobID = nil
message.blobThumbnailID = nil
message.caption = ""
message.encryptionKey = nil
message.fileName = ""
message.fileSize = nil
message.json = ""
message.mimeType = ""
message.origin = nil
message.progress = nil
message.type = nil
message.consumed = nil
message.thumbnail = nil
}
else if let message = message as? ImageMessageEntity {
deleteThumbnail(for: message)
message.encryptionKey = nil
message.imageBlobID = nil
message.imageSize = nil
message.progress = 0
message.imageBlobID = nil
message.thumbnail = nil
}
else if let message = message as? VideoMessageEntity {
deleteThumbnail(for: message)
message.duration = 0
message.encryptionKey = nil
message.progress = 0
message.videoBlobID = nil
message.videoSize = nil
message.duration = 0
}
if let historyEntries = message.historyEntries {
for history in historyEntries {
delete(entity: history)
}
}
if let reactions = message.reactions {
for reaction in reactions {
delete(entity: reaction)
}
}
}
private func deleteThumbnail(for message: BaseMessageEntity) {
guard message is FileMessageEntity || message is ImageMessageEntity || message is VideoMessageEntity else {
return
}
let thumbnailObjectIDs = message.objectIDs(forRelationshipNamed: "thumbnail")
guard !thumbnailObjectIDs.isEmpty else {
return
}
for objectID in thumbnailObjectIDs {
objCnx.performAndWait {
do {
let object = try self.objCnx.existingObject(with: objectID)
if message is FileMessageEntity || message is ImageMessageEntity {
self.objCnx.delete(object)
}
else if message is VideoMessageEntity,
let imageData = object as? ImageDataEntity,
let defaultThumbnail = UIImage(systemName: "film"),
let data = defaultThumbnail.jpegData(compressionQuality: 0.8) {
imageData.data = data
imageData.width = Int16(defaultThumbnail.size.width)
imageData.height = Int16(defaultThumbnail.size.height)
}
try self.objCnx.save()
}
catch {
DDLogError("Could not delete thumbnail. Error: \(error); \(error.localizedDescription)")
}
}
}
}
/// Delete all kind of messages.
///
/// - Parameters:
/// - olderThan: All message older than that date will be deleted
/// - conversation: Conversation
///
/// - Returns:
/// Count of deleted messages
public func deleteMessages(olderThan: Date?, for conversation: ConversationEntity? = nil) -> Int? {
let fetchMessages = NSFetchRequest<NSFetchRequestResult>(entityName: "Message")
if let conversation {
if let olderThan {
fetchMessages.predicate = NSPredicate(
format: "conversation == %@ && date < %@",
conversation,
olderThan as NSDate
)
}
else {
fetchMessages.predicate = NSPredicate(format: "conversation == %@", conversation)
}
}
else {
if let olderThan {
fetchMessages.predicate = NSPredicate(format: "date < %@", olderThan as NSDate)
}
}
let deletedMessages = deleteMessages(with: fetchMessages)
if deletedMessages > 0 {
NotificationCenter.default.post(
name: DatabaseContext.batchDeletedOldMessages,
object: nil
)
return deletedMessages
}
return nil
}
/// Delete the Messages dated before the passed date.
/// Currently all but open polls and starred messages are affected.
///
/// - Parameters:
/// - olderThan: All messages older than this date are deleted
/// - conversationIDs: Object IDs of the conversations the messages belong to
public func deleteMessagesForMessageRetention(olderThan: Date, for conversationsIDs: [NSManagedObjectID]) {
guard !conversationsIDs.isEmpty else {
return
}
self.messageRetentionDelete(olderThan: olderThan, for: conversationsIDs)
}
/// Fetch number of messages that will be deleted by message retention
/// Currently all but open polls and starred messages are affected.
///
/// - Parameters:
/// - olderThan: All message older than that date will be counted
/// - conversationsIDs: Object IDs of the conversations affected by the filter and counting
/// - Returns: the number of messages that would be deleted. Returns 0 if there are none or if the fetch fails
public func messagesToBeDeleted(olderThan: Date, for conversationsIDs: [NSManagedObjectID]) -> Int {
guard !conversationsIDs.isEmpty else {
return 0
}
do {
let fetchRequest = try self.messageRetentionFetchRequest(olderThan: olderThan, for: conversationsIDs)
return try self.objCnx.count(for: fetchRequest)
}
catch {
DDLogError("[EntityDestroyer] Failed to count messages to be deleted: \(error)")
return 0
}
}
/// Delete all kind of messages within conversation.
///
/// - Parameters:
/// - conversation: Delete all message of this ConversationEntity
///
/// - Returns:
/// Count of deleted messages
public func deleteMessages(of conversation: ConversationEntity) -> Int {
let fetchMessages = NSFetchRequest<NSFetchRequestResult>(entityName: "Message")
fetchMessages.predicate = NSPredicate(format: "conversation = %@", conversation)
let deletedMessages = deleteMessages(with: fetchMessages)
if deletedMessages > 0 {
NotificationCenter.default.post(
name: DatabaseContext.batchDeletedAllConversationMessages,
object: nil,
userInfo: [DatabaseContext.conversationObjectIDKey: conversation.objectID]
)
}
return deletedMessages
}
@objc public func delete(ballot: BallotEntity) {
delete(entity: ballot)
}
public func delete(reaction: MessageReactionEntity) {
delete(entity: reaction)
}
func delete(ballotChoice: BallotChoiceEntity) {
delete(entity: ballotChoice)
}
@objc public func delete(baseMessage: BaseMessageEntity) {
delete(entity: baseMessage)
}
public func delete(callEntity: CallEntity) {
delete(entity: callEntity)
}
public func delete(conversation: ConversationEntity) {
delete(entity: conversation)
}
#if DEBUG
@available(*, deprecated, message: "Use this function only for testing!")
/// - Parameter contactEntity: Core Data object
public func delete(contactEntity: ContactEntity) {
delete(entity: contactEntity)
}
#endif
public func delete(distributionListEntity: DistributionListEntity) {
delete(entity: distributionListEntity)
}
public func delete(groupCallEntity: GroupCallEntity) {
delete(entity: groupCallEntity)
}
func delete(groupEntity: GroupEntity) {
delete(entity: groupEntity)
}
public func delete(imageDataEntity: ImageDataEntity) {
delete(entity: imageDataEntity)
}
public func delete(lastGroupSyncRequestEntity: LastGroupSyncRequestEntity) {
delete(entity: lastGroupSyncRequestEntity)
}
public func delete(messageHistoryEntryEntity: MessageHistoryEntryEntity) {
delete(entity: messageHistoryEntryEntity)
}
public func delete(webClientSessionEntity: WebClientSessionEntity) {
delete(entity: webClientSessionEntity)
}
@objc public func delete(workAvailabilityStatus: WorkAvailabilityStatusEntity) {
delete(entity: workAvailabilityStatus)
}
/// Delete particular DB object.
///
/// - Parameters:
/// - object: object to delete
private func delete(entity object: NSManagedObject) {
var shouldUpdateConversationContent = false
if let conversation = object as? ConversationEntity {
let count = deleteMessages(of: conversation)
DDLogDebug("\(count) messages deleted from conversation")
}
else if let contact = object as? ContactEntity {
// Remove all conversations and messages for this contact
if let conversations = contact.conversations {
for conversation in conversations {
delete(entity: conversation)
}
}
// check all group conversations to delete messages from this contact
let count = deleteMessages(for: contact)
DDLogDebug("\(count) group messages deleted from contact")
if count > 0 {
shouldUpdateConversationContent = true
}
}
else if let distributionList = object as? DistributionListEntity {
delete(entity: distributionList.conversation)
}
let deleteFilenames = getExternalFilenames(ofMessages: [object], includeThumbnail: true)
deleteExternalFiles(list: deleteFilenames)
if let message = object as? BaseMessageEntity {
nullifyConversationLastMessage(for: [message])
}
objCnx.delete(object)
if shouldUpdateConversationContent {
// Update open chats content
DispatchQueue.main.async {
NotificationCenter.default.post(
name: DatabaseContext.batchDeletedOldMessages,
object: nil,
userInfo: nil
)
}
}
}
/// Delete 1:1 conversation of a contact.
///
/// - Parameters:
/// - contactEntity: Delete 1:1 conversation of this contact
/// - conversationWillBeDeleted: Called before the conversation will be delete
public func deleteOneToOneConversation(
for contactEntity: ContactEntity,
conversationWillBeDeleted: (ConversationEntity) -> Void
) {
if let conversations = contactEntity.conversations {
for conversation in conversations.filter({ !$0.isGroup }) {
conversationWillBeDeleted(conversation)
delete(conversation: conversation)
}
}
}
/// Delete `ContactEntity` of own identity.
///
/// - Parameter identity: The identity of the local user
/// - Returns: True own contact was found and deleted
public func deleteOwnContact(myIdentity: String?) -> Bool {
guard let myIdentity else {
DDLogError("[EntityDestroyer] Own contact cannot be deleted because no Threema-ID is in the keychain")
return false
}
let fetchContacts = NSFetchRequest<NSFetchRequestResult>(entityName: "Contact")
fetchContacts.predicate = NSPredicate(format: "identity = %@", myIdentity)
guard let entities = try? objCnx.fetch(fetchContacts) as? [ContactEntity], !entities.isEmpty else {
DDLogNotice("[EntityDestroyer] No own contact found to delete")
return false
}
for entity in entities {
delete(entity: entity)
}
return true
}
/// Get orphaned external files.
///
/// - Returns: List of orphaned files, count of files in DB
public func orphanedExternalFiles(appGroupID: String) -> (orphanedFiles: [String], totalFilesCount: Int) {
// Load all external filenames
if let files = FileUtility.shared
.dir(
at: FileUtility.shared.appDataDirectory(appGroupID: appGroupID)?
.appendingPathComponent("\(DatabaseManager.databaseExternalStoragePath)/")
),
!files.isEmpty {
// Load all filenames from DB
var filesInDB = [String]()
do {
let fetchRequests: [NSFetchRequest<NSManagedObject>] = [
"AudioData",
"FileData",
"ImageData",
"VideoData",
]
.map { NSFetchRequest<NSManagedObject>(entityName: $0) }
for fetchRequest in fetchRequests {
try autoreleasepool {
let totalItems = try objCnx.count(for: fetchRequest)
let increment = 20
let sequence = stride(from: 0, to: totalItems, by: increment)
for i in sequence {
try autoreleasepool {
fetchRequest.fetchOffset = i
fetchRequest.fetchLimit = increment
let items = try objCnx.fetch(fetchRequest)
for item in items {
objCnx.refresh(item, mergeChanges: true)
if let externalStorageInfo = item as? ExternalStorageInfo {
if let filename = externalStorageInfo.getFilename() {
filesInDB.append(filename)
}
}
}
}
}
}
}
}
catch let error as NSError {
DDLogError("Could not load filenames from DB. \(error), \(error.userInfo)")
}
// Filter orphaned external files
let orphanedFiles = files.map(\.lastPathComponent)
.filter { file -> Bool in
!filesInDB.contains(file)
}
#if DEBUG
for file in orphanedFiles {
DDLogInfo("Orphaned file: \(file)")
}
#endif
return (orphanedFiles, filesInDB.count)
}
return ([], 0)
}
public func deleteMissedCallsCacheOlderThanTwoWeeks() {
guard let twoWeeksAgo = Calendar.current.date(byAdding: .day, value: -14, to: Date()) as? NSDate else {
fatalError()
}
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Call")
fetchRequest.predicate = NSPredicate(format: "date <= %@", twoWeeksAgo)
do {
let batch = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batch.resultType = NSBatchDeleteRequestResultType.resultTypeObjectIDs
let deleteResult = try objCnx.execute(batch) as? NSBatchDeleteResult
if let deletedIDs = deleteResult?.result as? [NSManagedObjectID], !deletedIDs.isEmpty {
DatabaseContext.mergeChangesIntoMainContext([NSDeletedObjectsKey: deletedIDs])
DatabaseContext.mergeChangesIntoDirectContexts([NSDeletedObjectsKey: deletedIDs])
}
else {
// Fallback for when the batch delete request misses the objects stored in the DB
// This should only happen in unit tests but if it were to ever change and we'd miss it
// well still be safe in production.
guard let fetchedCalls = try? fetchRequest.execute() as? [NSManagedObject] else {
DDLogError("Could not delete calls cache. Unknown error.")
return
}
for fetchedCall in fetchedCalls {
delete(entity: fetchedCall)
}
}
}
catch let error as NSError {
DDLogError("Could not delete calls cache. \(error), \(error.userInfo)")
}
}
// MARK: - Private helper methods
private func messageRetentionFetchRequest(
olderThan: Date,
for conversationIDs: [NSManagedObjectID]
) throws -> NSFetchRequest<NSFetchRequestResult> {
guard let ballotMessageEntity = NSEntityDescription.entity(forEntityName: "BallotMessage", in: objCnx) else {
throw EntityDestroyerError.entityForDescriptionNotFound
}
let conversationAndDatePredicate = NSPredicate(
format: "conversation IN %@ AND date < %@",
conversationIDs,
olderThan as NSDate
)
// A ballot message is kept as long as its ballot is not closed
let isOpenBallotMessagePredicate = NSPredicate.and(
NSPredicate(format: "self.entity == %@", ballotMessageEntity),
.or(
NSPredicate(format: "ballotState == nil"),
NSPredicate(format: "ballotState != %d", BallotEntity.BallotState.closed.rawValue)
)
)
let isStarredPredicate = NSPredicate.and(
NSPredicate(format: "messageMarkers != nil"),
NSPredicate(format: "messageMarkers.star == YES")
)
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Message")
fetchRequest.predicate = .and(
conversationAndDatePredicate,
.not(.or(isOpenBallotMessagePredicate, isStarredPredicate))
)
return fetchRequest
}
private func messageRetentionDelete(olderThan: Date, for conversationIDs: [NSManagedObjectID]) {
guard !conversationIDs.isEmpty else {
return
}
do {
let fetchRequest = try messageRetentionFetchRequest(olderThan: olderThan, for: conversationIDs)
fetchRequest.resultType = .managedObjectIDResultType
let objectIDs = try objCnx.performAndWait {
try objCnx.fetch(fetchRequest) as? [NSManagedObjectID] ?? []
}
guard !objectIDs.isEmpty else {
DDLogNotice("[EntityDestroyer]: No messages to delete by automatic retention")
return
}
// Note: With a batch delete sometimes the external file of a file message will not be deleted immediately.
// If the external file is remaining, than the only way to delete it, is in Settings - Advanced - Delete
// Orphaned Files
let batch = NSBatchDeleteRequest(objectIDs: objectIDs)
batch.resultType = .resultTypeObjectIDs
// Update last message of affected conversations and external files of the deleted messages
nullifyConversationLastMessage(forObjectIDs: objectIDs)
let deleteFilenames = getExternalFilenames(ofObjectIDs: objectIDs, includeThumbnail: true)
deleteExternalFiles(list: deleteFilenames)
if let deleteResult = try objCnx.execute(batch) as? NSBatchDeleteResult,
let deletedIDs = deleteResult.result as? [NSManagedObjectID], !deletedIDs.isEmpty {
refreshDatabaseMainAndDirectContexts(for: deletedIDs)
Task.detached {
DatabaseContext.changed(objectIDs: Set(deletedIDs))
}
DDLogNotice(
"[EntityDestroyer]: Deleted \(deletedIDs.count) of \(objectIDs.count) messages by automatic retention"
)
NotificationCenter.default.post(
name: DatabaseContext.batchDeletedOldMessages,
object: nil
)
}
}
catch {
DDLogError("[EntityDestroyer]: Could not delete messages by automatic retention: \(error)")
}
}
private func getMediaMetaInfo(
messageType: (some Any)
.Type
) throws -> (fetchMessages: NSFetchRequest<NSManagedObject>, relationship: String, blobIDField: String) {
var fetchMessages: NSFetchRequest<NSManagedObject>
var relationship: String
var blobIDField: String
if messageType is AudioMessageEntity.Type {
fetchMessages = NSFetchRequest<NSManagedObject>(entityName: "AudioMessage")
relationship = AudioMessageEntity.Field.name(for: .audio, encrypted: isRemoteSecretEnabled)
blobIDField = AudioMessageEntity.Field.name(for: .audioBlobID, encrypted: isRemoteSecretEnabled)
}
else if messageType is FileMessageEntity.Type {
fetchMessages = NSFetchRequest<NSManagedObject>(entityName: "FileMessage")
relationship = FileMessageEntity.Field.name(for: .data, encrypted: isRemoteSecretEnabled)
blobIDField = FileMessageEntity.Field.name(for: .blobID, encrypted: isRemoteSecretEnabled)
}
else if messageType is ImageMessageEntity.Type {
fetchMessages = NSFetchRequest<NSManagedObject>(entityName: "ImageMessage")
relationship = ImageMessageEntity.Field.name(for: .image, encrypted: isRemoteSecretEnabled)
blobIDField = ImageMessageEntity.Field.name(for: .imageBlobID, encrypted: isRemoteSecretEnabled)
}
else if messageType is VideoMessageEntity.Type {
fetchMessages = NSFetchRequest<NSManagedObject>(entityName: "VideoMessage")
relationship = VideoMessageEntity.Field.name(for: .video, encrypted: isRemoteSecretEnabled)
blobIDField = VideoMessageEntity.Field.name(for: .videoBlobID, encrypted: isRemoteSecretEnabled)
}
else {
fatalError("message type not defined")
}
return (fetchMessages, relationship, blobIDField)
}
private func getExternalFilenames(ofObjectIDs: [NSManagedObjectID], includeThumbnail: Bool) -> [String] {
var externalFilenames = [String]()
do {
for entityName in ["AudioMessage", "FileMessage", "ImageMessage", "VideoMessage"] {
let fetch = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
fetch.predicate = NSPredicate(format: "self IN %@", ofObjectIDs)
let messages = try objCnx.fetch(fetch)
externalFilenames.append(
contentsOf: getExternalFilenames(ofMessages: messages, includeThumbnail: includeThumbnail)
)
}
}
catch {
DDLogError("Fetching messages to evaluate external files failed: \(error)")
}
return externalFilenames
}
/// List names of external files.
///
/// - Parameters:
/// - ofMessages: Check messages to external data files
/// - includeThumbnail: Check messages to external thumbnail files
///
/// - Returns: Names of external files
private func getExternalFilenames(ofMessages: [Any], includeThumbnail: Bool) -> [String] {
var externalFilenames: [String] = []
for message in ofMessages {
guard message is AudioMessageEntity || message is FileMessageEntity || message is ImageMessageEntity ||
message is VideoMessageEntity else {
continue
}
// Refreshing media objects, otherwise external filenames can not be evaluated for new messages
let mediaMetaInfo: (
fetchMessages: NSFetchRequest<NSManagedObject>,
relationship: String,
blobIDField: String
// swiftformat:disable:next wrapMultilineConditionalAssignment
)? = switch message {
case is AudioMessageEntity:
try? getMediaMetaInfo(messageType: AudioMessageEntity.self)
case is FileMessageEntity:
try? getMediaMetaInfo(messageType: FileMessageEntity.self)
case is ImageMessageEntity:
try? getMediaMetaInfo(messageType: ImageMessageEntity.self)
case is VideoMessageEntity:
try? getMediaMetaInfo(messageType: VideoMessageEntity.self)
default:
nil
}
if let relationship = mediaMetaInfo?.relationship,
let message = message as? NSManagedObject {
var mediaIDs = [NSManagedObjectID]()
mediaIDs.append(contentsOf: message.objectIDs(forRelationshipNamed: relationship))
for mediaID in mediaIDs {
if let mediaObj = try? objCnx.existingObject(with: mediaID) {
objCnx.refresh(mediaObj, mergeChanges: true)
}
}
}
// Get external file name
if let audioMessage = message as? AudioMessageEntity {
if let externalFileName = audioMessage.audio?.getFilename() {
externalFilenames.append(externalFileName)
}
}
else if let fileMessage = message as? FileMessageEntity {
if let externalFileName = fileMessage.data?.getFilename() {
externalFilenames.append(externalFileName)
}
if includeThumbnail, let thumbnailFileName = fileMessage.thumbnail?.getFilename() {
externalFilenames.append(thumbnailFileName)
}
}
else if let imageMessage = message as? ImageMessageEntity {
if let externalFileName = imageMessage.image?.getFilename() {
externalFilenames.append(externalFileName)
}
if includeThumbnail, let thumbnailFileName = imageMessage.thumbnail?.getFilename() {
externalFilenames.append(thumbnailFileName)
}
}
else if let videoMessage = message as? VideoMessageEntity {
if let externalFileName = videoMessage.video?.getFilename() {
externalFilenames.append(externalFileName)
}
if includeThumbnail, let thumbnailFileName = videoMessage.thumbnail?.getFilename() {
externalFilenames.append(thumbnailFileName)
}
}
}
return externalFilenames
}
/// Delete external files.
///
/// - Parameters:
/// - list: List of filenames to delete
private func deleteExternalFiles(list: [String]) {
// It seams, since iOS version 15 is this not necessary anymore. Core Data deleting now the external files.
// If we do that yet Core Data crashes with: "External data reference can't find underlying file."
// swiftformat:disable:next all
return;
// if !list.isEmpty {
// for filename in list {
// let fileURL = FileUtility.shared.appDataDirectory(appGroupID: AppGroup.groupID())?
// .appendingPathComponent("\(DatabaseManager.databaseExternalStoragePath)/\(filename)")
// FileUtility.shared.deleteIfExists(at: fileURL)
// }
// }
}
/// Delete all kind of messages of this contact.
///
/// - Parameters:
/// - contact: Delete all messages of this contact
///
/// - Returns:
/// Count of deleted messages
private func deleteMessages(for contact: ContactEntity) -> Int {
let fetchMessages = NSFetchRequest<NSFetchRequestResult>(entityName: "Message")
fetchMessages.predicate = NSPredicate(format: "sender = %@", contact)
return deleteMessages(with: fetchMessages)
}
/// Delete all kind of messages of the fetch request.
///
/// - Parameters:
/// - fetchRequest: NSFetchRequest<NSFetchRequestResult>
///
/// - Returns:
/// Count of deleted messages
private func deleteMessages(with fetchRequest: NSFetchRequest<NSFetchRequestResult>) -> Int {
do {
fetchRequest.resultType = NSFetchRequestResultType.managedObjectIDResultType
guard let objectIDs = try objCnx.fetch(fetchRequest) as? [NSManagedObjectID], !objectIDs.isEmpty else {
return 0
}
let deleteFilenames = getExternalFilenames(ofObjectIDs: objectIDs, includeThumbnail: true)
nullifyConversationLastMessage(forObjectIDs: objectIDs)
// With a batch delete sometimes the external file of a file message will not be deleted immediately. If the
// external file is remaining, than the only way to delete it, is in Settings - Advanced - Delete Orphaned
// Files
let batch = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batch.resultType = NSBatchDeleteRequestResultType.resultTypeObjectIDs
if let deleteResult = try objCnx.execute(batch) as? NSBatchDeleteResult,
let deletedIDs = deleteResult.result as? [NSManagedObjectID], !deletedIDs.isEmpty {
refreshDatabaseMainAndDirectContexts(for: deletedIDs)
deleteExternalFiles(list: deleteFilenames)
return deletedIDs.count
}
return 0
}
catch let error as NSError {
DDLogError("[Entity Destroyer] Could not delete messages. \(error), \(error.userInfo)")
}
return 0
}
private func nullifyConversationLastMessage(for messages: [BaseMessageEntity]) {
guard !messages.isEmpty else {
return
}
nullifyConversationLastMessage(forObjectIDs: messages.map(\.objectID))
}
private func nullifyConversationLastMessage(forObjectIDs: [NSManagedObjectID]) {
guard !forObjectIDs.isEmpty else {
return
}
do {
try objCnx.performAndWait {
let fetchConversations = NSFetchRequest<NSFetchRequestResult>(entityName: "Conversation")
fetchConversations.predicate = NSPredicate(format: "lastMessage IN %@", forObjectIDs)
if let conversations = try fetchConversations.execute() as? [ConversationEntity],
!conversations.isEmpty {
DDLogNotice("[Entity Destroyer]: Reset las message for \(conversations.count) conversation")
for conversation in conversations {
conversation.lastMessage = nil
}
try self.objCnx.save()
}
}
}
catch {
DDLogError("[Entity Destroyer]: Failed to nullify `Conversation.lastMessage`: \(error)")
}
}
/// Merge deleted objects in private (background) context to main and direct database contexts.
/// - Parameter deletedIDs: Array with object ID of deleted objects
private func refreshDatabaseMainAndDirectContexts(for deletedIDs: [NSManagedObjectID]) {
guard !deletedIDs.isEmpty else {
return
}
// Merge the deletions into the app's managed object context.
let deletedObjects = [NSDeletedObjectsKey: deletedIDs]
DatabaseContext.mergeChangesIntoMainContext(deletedObjects)
DatabaseContext.mergeChangesIntoDirectContexts(deletedObjects)
}
}
¤ Dauer der Verarbeitung: 0.8 Sekunden
(vorverarbeitet am 2026-11-04)
¤
*© Formatika GbR, Deutschland
|
|