// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2019-2020 Threema GmbH // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License, version 3, // as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import Foundation @objc public class EntityDestroyer: NSObject { private let objCnx: NSManagedObjectContext private let logger: ValidationLogger @objc public required init(managedObjectContext: NSManagedObjectContext) { self.objCnx = managedObjectContext self.logger = ValidationLogger.shared() } /** Delete media files of audio, file, image and video messages. - Parameters: - olderThan: All message older than that date will be deleted - Returns: Count of deleted media files */ public func deleteMedias(olderThan: Date?) -> Int? { var deletedObjects: Int = 0 if let count = self.deleteMediasOf(messageType: AudioMessage.self, olderThan: olderThan) { deletedObjects += count } if let count = self.deleteMediasOf(messageType: FileMessage.self, olderThan: olderThan) { deletedObjects += count } if let count = self.deleteMediasOf(messageType: ImageMessage.self, olderThan: olderThan) { deletedObjects += count } if let count = self.deleteMediasOf(messageType: VideoMessage.self, olderThan: olderThan) { deletedObjects += count } return deletedObjects } func deleteMediasOf(messageType: T.Type, olderThan: Date?) -> Int? { guard messageType is AudioMessage.Type || messageType is FileMessage.Type || messageType is ImageMessage.Type || messageType is VideoMessage.Type else { return nil } do { let mediaMetaInfo = try getMediaMetaInfo(messageType: messageType) if let olderThan = olderThan { mediaMetaInfo.fetchMessages.predicate = NSPredicate(format: "%K != nil AND date < %@", mediaMetaInfo.relationship, olderThan as NSDate) } else { mediaMetaInfo.fetchMessages.predicate = NSPredicate(format: "%K != nil", mediaMetaInfo.relationship) } let messages = try self.objCnx.fetch(mediaMetaInfo.fetchMessages) var deleteMediaIDs: [NSManagedObjectID] = [] var updateMessageIDs: [NSManagedObjectID] = [] for message in messages { if let message = message as? T { deleteMediaIDs.append(contentsOf: message.objectIDs(forRelationshipNamed: mediaMetaInfo.relationship)) updateMessageIDs.append(message.objectID) } } let deleteFilenames = self.getExternalFilenames(ofMessages: messages, includeThumbnail: false) if deleteMediaIDs.count > 0 { var changes = [AnyHashable : [NSManagedObjectID]]() // Delete medias let deleteMediaBatch = NSBatchDeleteRequest(objectIDs: deleteMediaIDs) deleteMediaBatch.resultType = .resultTypeObjectIDs let deleteMediaResult = try self.objCnx.execute(deleteMediaBatch) as? NSBatchDeleteResult if let deletedIDs = deleteMediaResult?.result as? [NSManagedObjectID] { changes[NSDeletedObjectsKey] = deletedIDs } // Update blobIDs to nil (to prevent downloading blob again) if updateMessageIDs.count > 0 { var updatedIDs: [NSManagedObjectID] = [] for updateID in updateMessageIDs { if let updateMessage = try self.objCnx.existingObject(with: updateID) as? T { if updateMessage.value(forKey: mediaMetaInfo.blobIDField) != nil { self.objCnx.performAndWait { updateMessage.setValue(nil, forKey: mediaMetaInfo.blobIDField) do { try self.objCnx.save() updatedIDs.append(updateID) } catch let error as NSError { self.logger.logString("Cloud not update message. \(error), \(error.userInfo)") } } } } } changes[NSUpdatedObjectsKey] = updatedIDs } if changes.count > 0 { let dbManager = DatabaseManager() dbManager.refreshDirtyObjectIDs(changes, into:self.objCnx) } self.deleteExternalFiles(list: deleteFilenames) } return messages.count } catch let error as NSError { self.logger.logString("Could not delete medias. \(error), \(error.userInfo)") } return 0; } /** Delete all kind of messages. - Parameters: - olderThan: All message older than that date will be deleted - Returns: Count of deleted messages */ public func deleteMessages(olderThan: Date?) -> Int? { do { let fetchMessages = NSFetchRequest(entityName: "Message") if let olderThan = olderThan { fetchMessages.predicate = NSPredicate(format: "date < %@", olderThan as NSDate) } let messages = try self.objCnx.fetch(fetchMessages) let deleteFilenames = self.getExternalFilenames(ofMessages: messages, includeThumbnail: true) let batch = NSBatchDeleteRequest(fetchRequest: fetchMessages) batch.resultType = NSBatchDeleteRequestResultType.resultTypeObjectIDs let deleteResult = try self.objCnx.execute(batch) as? NSBatchDeleteResult if let deletedIDs = deleteResult?.result as? [NSManagedObjectID] { let dbManager = DatabaseManager() dbManager.refreshDirtyObjectIDs([NSDeletedObjectsKey: deletedIDs], into:self.objCnx) self.deleteExternalFiles(list: deleteFilenames) return deletedIDs.count } return nil } catch let error as NSError { self.logger.logString("Could not delete messages. \(error), \(error.userInfo)") } return nil } /** Delete all kind of messages within conversation. - Parameters: - ofConversation: Delete all message of this conversation - Returns: Count of deleted messages */ @objc public func deleteMessages(ofCoversation: Conversation) -> Int { do { let fetchMessages = NSFetchRequest(entityName: "Message") fetchMessages.predicate = NSPredicate(format: "conversation = %@", ofCoversation) let messages = try self.objCnx.fetch(fetchMessages) let deleteFilenames = self.getExternalFilenames(ofMessages: messages, includeThumbnail: true) let batch = NSBatchDeleteRequest(fetchRequest: fetchMessages) batch.resultType = NSBatchDeleteRequestResultType.resultTypeObjectIDs let deleteResult = try self.objCnx.execute(batch) as? NSBatchDeleteResult if let deletedIDs = deleteResult?.result as? [NSManagedObjectID] { let dbManager = DatabaseManager() dbManager.refreshDirtyObjectIDs([NSDeletedObjectsKey: deletedIDs], into:self.objCnx) self.deleteExternalFiles(list: deleteFilenames) return deletedIDs.count } return 0 } catch let error as NSError { self.logger.logString("Could not delete messages. \(error), \(error.userInfo)") } return 0 } /** Delete particular DB object. - Parameters: - object: object to delete */ @objc public func deleteObject(object: NSManagedObject) { if let conversation = object as? Conversation { let count = deleteMessages(ofCoversation: conversation) print("\(count) messages deleted from conversation") } let deleteFilenames = self.getExternalFilenames(ofMessages: [object], includeThumbnail: true) self.objCnx.delete(object) self.deleteExternalFiles(list: deleteFilenames) } private func getMediaMetaInfo(messageType: T.Type) throws -> (fetchMessages: NSFetchRequest, relationship: String, blobIDField: String) { var fetchMessages: NSFetchRequest var relationship: String var blobIDField: String if messageType is AudioMessage.Type { fetchMessages = NSFetchRequest(entityName: "AudioMessage") relationship = "audio" blobIDField = "audioBlobId" } else if messageType is FileMessage.Type { fetchMessages = NSFetchRequest(entityName: "FileMessage") relationship = "data" blobIDField = "blobId" } else if messageType is ImageMessage.Type { fetchMessages = NSFetchRequest(entityName: "ImageMessage") relationship = "image" blobIDField = "imageBlobId" } else if messageType is VideoMessage.Type { fetchMessages = NSFetchRequest(entityName: "VideoMessage") relationship = "video" blobIDField = "videoBlobId" } else { fatalError("message type not defined") } return (fetchMessages, relationship, blobIDField) } /** 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 { if let externalStorageInfo = message as? ExternalStorageInfo { // Refreshing media objects, otherwise external filenames can not be evaluated for new messages let mediaMetaInfo: (fetchMessages: NSFetchRequest, relationship: String, blobIDField: String)? switch message { case is AudioMessage: mediaMetaInfo = try? getMediaMetaInfo(messageType: AudioMessage.self) case is FileMessage: mediaMetaInfo = try? getMediaMetaInfo(messageType: FileMessage.self) case is ImageMessage: mediaMetaInfo = try? getMediaMetaInfo(messageType: ImageMessage.self) case is VideoMessage: mediaMetaInfo = try? getMediaMetaInfo(messageType: VideoMessage.self) default: mediaMetaInfo = 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 filename = externalStorageInfo.getFilename() { externalFilenames.append(filename) } if includeThumbnail, let thumbnailname = externalStorageInfo.getThumbnailname?() { externalFilenames.append(thumbnailname) } } } return externalFilenames; } /** Delete external files. - Parameters: - list: List of filenames to delete */ private func deleteExternalFiles(list: [String]) { if list.count > 0 { for filename in list { let fileUrl = FileUtility.appDataDirectory?.appendingPathComponent(".ThreemaData_SUPPORT/_EXTERNAL_DATA/\(filename)") FileUtility.delete(fileUrl: fileUrl) } } } }