diff --git a/Mixin.xcodeproj/project.pbxproj b/Mixin.xcodeproj/project.pbxproj index 595545fba2..0030704a80 100644 --- a/Mixin.xcodeproj/project.pbxproj +++ b/Mixin.xcodeproj/project.pbxproj @@ -686,6 +686,7 @@ 947E0659279867870002669B /* PINIteratorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947E0658279867870002669B /* PINIteratorTest.swift */; }; 947F4AD625866D6C00B0A5F9 /* InitializeFTSJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947F4AD525866D6C00B0A5F9 /* InitializeFTSJob.swift */; }; 94812DDA26E082C400213F79 /* mixin_condensed.otf in Resources */ = {isa = PBXBuildFile; fileRef = 94812DD926E082C400213F79 /* mixin_condensed.otf */; }; + 948983B1284E66C00065DC5D /* ConversationCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948983B0284E66C00065DC5D /* ConversationCleaner.swift */; }; 9489E3A025C5B150000319F8 /* AcknowledgementsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9489E39F25C5B150000319F8 /* AcknowledgementsViewController.swift */; }; 9492285F25DFB7D60000A19F /* MinimizedPlaylistViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9492285E25DFB7D60000A19F /* MinimizedPlaylistViewController.swift */; }; 9492286425DFB7EF0000A19F /* MinimizedPlaylistView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9492286325DFB7EF0000A19F /* MinimizedPlaylistView.xib */; }; @@ -1685,6 +1686,7 @@ 947F4AD525866D6C00B0A5F9 /* InitializeFTSJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitializeFTSJob.swift; sourceTree = ""; }; 94812DD926E082C400213F79 /* mixin_condensed.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = mixin_condensed.otf; sourceTree = ""; }; 94841CC72797D89500B3593B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 948983B0284E66C00065DC5D /* ConversationCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCleaner.swift; sourceTree = ""; }; 9489E39F25C5B150000319F8 /* AcknowledgementsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsViewController.swift; sourceTree = ""; }; 9492285E25DFB7D60000A19F /* MinimizedPlaylistViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinimizedPlaylistViewController.swift; sourceTree = ""; }; 9492286325DFB7EF0000A19F /* MinimizedPlaylistView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MinimizedPlaylistView.xib; sourceTree = ""; }; @@ -2436,6 +2438,7 @@ E0A14D3B2372F0340044D131 /* GroupProfileViewController.swift */, 7B5E9B3F243700CD000AE24E /* ConversationCircleEditorViewController.swift */, 7CB2A57E27C386F0007D9DEE /* GroupsInCommonViewController.swift */, + 948983B0284E66C00065DC5D /* ConversationCleaner.swift */, ); path = Common; sourceTree = ""; @@ -4799,6 +4802,7 @@ 7BC80A62221D1C07008586AD /* AssetTableHeaderView.swift in Sources */, 7B9553402243860C00CE95E6 /* PinValidationPresentationManager.swift in Sources */, 94046B93272DC28B007C1D4A /* GroupCallMembersManager.swift in Sources */, + 948983B1284E66C00065DC5D /* ConversationCleaner.swift in Sources */, 7B4C6571242358D5003B78F9 /* LocationSearchNoResultView.swift in Sources */, DF5D9F291F9C79E10036D5FD /* UIColorExtension.swift in Sources */, DF0A0A5E24476EA000378B4F /* RefreshOffsetJob.swift in Sources */, diff --git a/Mixin/Service/Audio/Playlist/PlaylistManager.swift b/Mixin/Service/Audio/Playlist/PlaylistManager.swift index 81c2a34938..3f6344dc45 100644 --- a/Mixin/Service/Audio/Playlist/PlaylistManager.swift +++ b/Mixin/Service/Audio/Playlist/PlaylistManager.swift @@ -167,12 +167,12 @@ class PlaylistManager: NSObject { name: MessageDAO.didInsertMessageNotification, object: nil) notificationCenter.addObserver(self, - selector: #selector(messageDAOWillDeleteMessage(_:)), - name: MessageDAO.willDeleteMessageNotification, + selector: #selector(messageWillDelete(_:)), + name: DeleteMessageAttachmentWork.willDeleteNotification, object: nil) notificationCenter.addObserver(self, - selector: #selector(conversationDAOWillClearConversation(_:)), - name: ConversationDAO.willClearConversationNotification, + selector: #selector(conversationWillClean(_:)), + name: ConversationCleaner.willCleanNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(messageServiceWillRecallMessage(_:)), @@ -773,8 +773,8 @@ extension PlaylistManager { loadLaterItemsIfNeeded() } - @objc private func messageDAOWillDeleteMessage(_ notification: Notification) { - guard let messageId = notification.userInfo?[MessageDAO.UserInfoKey.messageId] as? String else { + @objc private func messageWillDelete(_ notification: Notification) { + guard let messageId = notification.userInfo?[DeleteMessageAttachmentWork.messageIdUserInfoKey] as? String else { return } guard case .conversation = source else { @@ -783,8 +783,8 @@ extension PlaylistManager { removeItem(with: messageId) } - @objc private func conversationDAOWillClearConversation(_ notification: Notification) { - guard let id = notification.userInfo?[ConversationDAO.conversationIdUserInfoKey] as? String else { + @objc private func conversationWillClean(_ notification: Notification) { + guard let id = notification.userInfo?[ConversationCleaner.conversationIdUserInfoKey] as? String else { return } guard case let .conversation(conversationId) = source, conversationId == id else { diff --git a/Mixin/Service/Job/AttachmentUploadJob.swift b/Mixin/Service/Job/AttachmentUploadJob.swift index a691700f01..08dad86735 100644 --- a/Mixin/Service/Job/AttachmentUploadJob.swift +++ b/Mixin/Service/Job/AttachmentUploadJob.swift @@ -78,7 +78,8 @@ class AttachmentUploadJob: AttachmentLoadingJob { return false } guard let fileUrl = fileUrl else { - MessageDAO.shared.deleteMessage(id: messageId) + let work = DeleteMessageAttachmentWork(message: message) + WorkManager.general.addWork(work) return false } diff --git a/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift b/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift index 1774c3d549..77416bf322 100644 --- a/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift @@ -272,11 +272,8 @@ class ConversationInputViewController: UIViewController { self.deleteConversationButton.isBusy = false })) alert.addAction(UIAlertAction(title: R.string.localizable.delete_chat(), style: .destructive, handler: { (_) in - DispatchQueue.global().async { [weak self] in - ConversationDAO.shared.deleteChat(conversationId: conversationId) - DispatchQueue.main.async { - self?.navigationController?.backToHome() - } + ConversationCleaner.clean(conversationId: conversationId, intent: .delete) { + self.navigationController?.backToHome() } })) present(alert, animated: true, completion: nil) diff --git a/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift b/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift index 683e5e1398..4efd9dcc70 100644 --- a/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift @@ -2706,9 +2706,11 @@ extension ConversationViewController { guard let weakSelf = self, let indexPath = weakSelf.dataSource.indexPath(where: { $0.messageId == message.messageId }) else { return } - let (deleted, childMessageIds) = MessageDAO.shared.deleteMessage(id: message.messageId) - if deleted { - ReceiveMessageService.shared.stopRecallMessage(item: message, childMessageIds: childMessageIds) + MessageDAO.shared.delete(id: message.messageId, conversationId: message.conversationId, deleteTranscriptChildren: false) { db in + if DeleteMessageAttachmentWork.capableMessageCategories.contains(message.category) { + let work = DeleteMessageAttachmentWork(message: message) + WorkManager.general.addPersistableWork(work, alongsideTransactionWith: db) + } } DispatchQueue.main.sync { _ = weakSelf.dataSource?.removeViewModel(at: indexPath) diff --git a/Mixin/UserInterface/Controllers/Common/ConversationCleaner.swift b/Mixin/UserInterface/Controllers/Common/ConversationCleaner.swift new file mode 100644 index 0000000000..953276745d --- /dev/null +++ b/Mixin/UserInterface/Controllers/Common/ConversationCleaner.swift @@ -0,0 +1,77 @@ +import Foundation +import MixinServices +import GRDB + +enum ConversationCleaner { + + public static let willCleanNotification = Notification.Name("one.mixin.messenger.ConversationCleaner.willClean") + public static let conversationIdUserInfoKey = "cid" + + enum Intent { + case delete + case clear + } + + static func clean(conversationId: String, intent: Intent, completion: (() -> Void)? = nil) { + let hud = Hud() + hud.show(style: .busy, text: "", on: AppDelegate.current.mainWindow) + NotificationCenter.default.post(name: Self.willCleanNotification, + object: self, + userInfo: [Self.conversationIdUserInfoKey: conversationId]) + DispatchQueue.global().async { + UserDatabase.current.write { db in + let categories = MessageCategory.allMediaCategoriesString.joined(separator: "', '") + let sql = "SELECT media_url, category FROM messages WHERE conversation_id = ? AND category IN ('\(categories)') AND media_url IS NOT NULL" + let attachments = try DeleteConversationAttachmentWork.Attachment.fetchAll(db, sql: sql, arguments: [conversationId]) + let transcriptMessageIds = try MessageDAO.shared.getTranscriptMessageIds(conversationId: conversationId, database: db) + if !attachments.isEmpty || !transcriptMessageIds.isEmpty { + let work = DeleteConversationAttachmentWork(attachments: attachments, transcriptMessageIds: transcriptMessageIds) + WorkManager.general.addPersistableWork(work, alongsideTransactionWith: db) + } + + try Message + .filter(Message.column(of: .conversationId) == conversationId) + .deleteAll(db) + try MessageMention + .filter(MessageMention.column(of: .conversationId) == conversationId) + .deleteAll(db) + + switch intent { + case .delete: + try Conversation + .filter(Conversation.column(of: .conversationId) == conversationId) + .deleteAll(db) + try Participant + .filter(Participant.column(of: .conversationId) == conversationId) + .deleteAll(db) + try ParticipantSession + .filter(ParticipantSession.column(of: .conversationId) == conversationId) + .deleteAll(db) + case .clear: + try Conversation + .filter(Conversation.column(of: .conversationId) == conversationId) + .updateAll(db, [Conversation.column(of: .unseenMessageCount).set(to: 0)]) + } + + try ConversationDAO.shared.deleteFTSContent(with: conversationId, from: db) + try PinMessageDAO.shared.deleteAll(conversationId: conversationId, from: db) + db.afterNextTransactionCommit { (_) in + DispatchQueue.main.async { + switch intent { + case .delete: + NotificationCenter.default.post(name: conversationDidChangeNotification, object: nil) + hud.set(style: .notification, text: R.string.localizable.deleted()) + case .clear: + let change = ConversationChange(conversationId: conversationId, action: .reload) + NotificationCenter.default.post(name: conversationDidChangeNotification, object: change) + hud.set(style: .notification, text: R.string.localizable.cleared()) + } + hud.scheduleAutoHidden() + completion?() + } + } + } + } + } + +} diff --git a/Mixin/UserInterface/Controllers/Common/GroupProfileViewController.swift b/Mixin/UserInterface/Controllers/Common/GroupProfileViewController.swift index e36be084de..8a0ba24c70 100644 --- a/Mixin/UserInterface/Controllers/Common/GroupProfileViewController.swift +++ b/Mixin/UserInterface/Controllers/Common/GroupProfileViewController.swift @@ -207,21 +207,11 @@ extension GroupProfileViewController { let conversationId = conversation.conversationId let alert = UIAlertController(title: R.string.localizable.delete_group_chat_confirmation(conversation.name), message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) - alert.addAction(UIAlertAction(title: R.string.localizable.delete_chat(), style: .destructive, handler: { [weak self](_) in - let hud = Hud() - hud.show(style: .busy, text: "", on: AppDelegate.current.mainWindow) - DispatchQueue.global().async { - ConversationDAO.shared.deleteChat(conversationId: conversationId) - DispatchQueue.main.async { - guard let self = self else { - return - } - self.dismiss(animated: true) { - hud.set(style: .notification, text: R.string.localizable.done()) - hud.scheduleAutoHidden() - if UIApplication.currentConversationId() == conversationId { - UIApplication.homeNavigationController?.backToHome() - } + alert.addAction(UIAlertAction(title: R.string.localizable.delete_chat(), style: .destructive, handler: { _ in + ConversationCleaner.clean(conversationId: conversationId, intent: .delete) { + self.dismiss(animated: true) { + if UIApplication.currentConversationId() == conversationId { + UIApplication.homeNavigationController?.backToHome() } } } diff --git a/Mixin/UserInterface/Controllers/Common/ProfileViewController.swift b/Mixin/UserInterface/Controllers/Common/ProfileViewController.swift index 601580c0d7..355b8bb100 100644 --- a/Mixin/UserInterface/Controllers/Common/ProfileViewController.swift +++ b/Mixin/UserInterface/Controllers/Common/ProfileViewController.swift @@ -256,12 +256,7 @@ extension ProfileViewController { alert.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) alert.addAction(UIAlertAction(title: R.string.localizable.clear_chat(), style: .destructive, handler: { (_) in self.dismiss(animated: true, completion: nil) - DispatchQueue.global().async { - ConversationDAO.shared.clearChat(conversationId: conversationId) - DispatchQueue.main.async { - showAutoHiddenHud(style: .notification, text: R.string.localizable.cleared()) - } - } + ConversationCleaner.clean(conversationId: conversationId, intent: .clear) })) present(alert, animated: true, completion: nil) } diff --git a/Mixin/UserInterface/Controllers/Home/HomeViewController.swift b/Mixin/UserInterface/Controllers/Home/HomeViewController.swift index c0ab4724a3..ac78139b7f 100644 --- a/Mixin/UserInterface/Controllers/Home/HomeViewController.swift +++ b/Mixin/UserInterface/Controllers/Home/HomeViewController.swift @@ -145,6 +145,10 @@ class HomeViewController: UIViewController { if AppGroupUserDefaults.User.hasRecoverMedia { ConcurrentJobQueue.shared.addJob(job: RecoverMediaJob()) } + WorkManager.general.wakeUpPersistedWorks(with: [ + DeleteMessageAttachmentWork.self, + DeleteConversationAttachmentWork.self + ]) initializeFTSIfNeeded() refreshExternalSchemesIfNeeded() } @@ -739,9 +743,7 @@ extension HomeViewController { self.conversations.remove(at: indexPath.row) self.tableView.deleteRows(at: [indexPath], with: .fade) self.tableView.endUpdates() - DispatchQueue.global().async { - ConversationDAO.shared.deleteChat(conversationId: conversationId) - } + ConversationCleaner.clean(conversationId: conversationId, intent: .delete) })) present(alert, animated: true, completion: nil) } @@ -763,9 +765,7 @@ extension HomeViewController { self.conversations[indexPath.row].unseenMessageCount = 0 self.tableView.reloadRows(at: [indexPath], with: .automatic) self.tableView.endUpdates() - DispatchQueue.global().async { - ConversationDAO.shared.clearChat(conversationId: conversationId) - } + ConversationCleaner.clean(conversationId: conversationId, intent: .clear) })) present(alert, animated: true, completion: nil) } diff --git a/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift b/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift index 12d48c4ff1..e9f6a649a1 100644 --- a/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift @@ -4,9 +4,6 @@ public final class ConversationDAO: UserDatabaseDAO { public static let shared = ConversationDAO() - public static let willClearConversationNotification = Notification.Name("one.mixin.service.ConversationDAO.willClearConversation") - public static let conversationIdUserInfoKey = "cid" - private static let sqlQueryColumns = """ SELECT c.conversation_id as conversationId, c.owner_id as ownerId, c.icon_url as iconUrl, c.announcement as announcement, c.category as category, c.name as name, c.status as status, @@ -231,35 +228,6 @@ public final class ConversationDAO: UserDatabaseDAO { } } - public func clearChat(conversationId: String) { - let mediaUrls = MessageDAO.shared.getMediaUrls(conversationId: conversationId, categories: MessageCategory.allMediaCategories) - db.write { db in - let deletedTranscriptIds = try deleteTranscriptChildrenReferenced(by: conversationId, from: db) - NotificationCenter.default.post(onMainThread: Self.willClearConversationNotification, - object: self, - userInfo: [Self.conversationIdUserInfoKey: conversationId]) - try Message - .filter(Message.column(of: .conversationId) == conversationId) - .deleteAll(db) - try MessageMention - .filter(MessageMention.column(of: .conversationId) == conversationId) - .deleteAll(db) - try Conversation - .filter(Conversation.column(of: .conversationId) == conversationId) - .updateAll(db, [Conversation.column(of: .unseenMessageCount).set(to: 0)]) - try deleteFTSContent(with: conversationId, from: db) - try PinMessageDAO.shared.deleteAll(conversationId: conversationId, from: db) - db.afterNextTransactionCommit { (_) in - let job = AttachmentCleanUpJob(conversationId: conversationId, - mediaUrls: mediaUrls, - transcriptIds: deletedTranscriptIds) - ConcurrentJobQueue.shared.addJob(job: job) - let change = ConversationChange(conversationId: conversationId, action: .reload) - NotificationCenter.default.post(onMainThread: conversationDidChangeNotification, object: change) - } - } - } - public func getConversation(conversationId: String) -> ConversationItem? { guard !conversationId.isEmpty else { return nil @@ -595,7 +563,7 @@ public final class ConversationDAO: UserDatabaseDAO { extension ConversationDAO { - private func deleteFTSContent(with conversationId: String, from db: GRDB.Database) throws { + public func deleteFTSContent(with conversationId: String, from db: GRDB.Database) throws { let sql = "DELETE FROM \(Message.ftsTableName) WHERE conversation_id MATCH ?" try db.execute(sql: sql, arguments: [uuidTokenString(uuidString: conversationId)]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift index 95d42db888..dd300ba255 100644 --- a/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/ExpiredMessageDAO.swift @@ -101,7 +101,10 @@ public final class ExpiredMessageDAO: UserDatabaseDAO { var unseenCountChangedConversationIds: Set = [] let expiredMessages = try MessageDAO.shared.getFullMessages(messageIds: expiredMessageIds, with: db) for message in expiredMessages { - let (deleted, childMessageIds) = try MessageDAO.shared.deleteMessage(id: message.messageId, with: db) + let (deleted, childMessageIds) = try MessageDAO.shared.delete(id: message.messageId, + conversationId: message.conversationId, + deleteTranscriptChildren: true, + database: db) if deleted { deletedMessages.append((message, childMessageIds)) if message.status != MessageStatus.READ.rawValue { diff --git a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift index 7f78e6c18a..3b8ac87d58 100644 --- a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift @@ -14,7 +14,6 @@ public final class MessageDAO: UserDatabaseDAO { public static let shared = MessageDAO() - public static let willDeleteMessageNotification = Notification.Name("one.mixin.services.MessageDAO.willDeleteMessage") public static let didInsertMessageNotification = Notification.Name("one.mixin.services.did.insert.msg") public static let didRedecryptMessageNotification = Notification.Name("one.mixin.services.did.redecrypt.msg") public static let messageMediaStatusDidUpdateNotification = Notification.Name("one.mixin.services.MessageDAO.MessageMediaStatusDidUpdate") @@ -764,47 +763,45 @@ public final class MessageDAO: UserDatabaseDAO { } } - @discardableResult - public func deleteMessage(id: String) -> (deleted: Bool, childMessageIds: [String]) { - NotificationCenter.default.post(onMainThread: Self.willDeleteMessageNotification, - object: self, - userInfo: [UserInfoKey.messageId: id]) - var deleted = false - var childMessageIds: [String] = [] - db.write { (db) in - (deleted, childMessageIds) = try deleteMessage(id: id, with: db) - } - return (deleted, childMessageIds) - } - - func deleteMessage(id: String, with database: GRDB.Database) throws -> (deleted: Bool, childMessageIds: [String]) { - var deleteCount = 0 - var childMessageIds: [String] = [] - let conversationId: String? = try Message - .select(Message.column(of: .conversationId)) - .filter(Message.column(of: .messageId) == id) - .fetchOne(database) - deleteCount = try Message + func delete(id: String, conversationId: String, deleteTranscriptChildren: Bool, database: GRDB.Database) throws -> (deleted: Bool, childMessageIds: [String]) { + let deleteCount = try Message .filter(Message.column(of: .messageId) == id) .deleteAll(database) try MessageMention .filter(MessageMention.column(of: .messageId) == id) .deleteAll(database) try deleteFTSContent(database, messageId: id) - childMessageIds = try TranscriptMessage + let childMessageIds: [String] = try TranscriptMessage .select(TranscriptMessage.column(of: .messageId)) .filter(TranscriptMessage.column(of: .transcriptId) == id) .fetchAll(database) - try TranscriptMessage - .filter(TranscriptMessage.column(of: .transcriptId) == id) - .deleteAll(database) - if let conversationId = conversationId { - try PinMessageDAO.shared.delete(messageIds: [id], conversationId: conversationId, from: database) - try clearPinMessageContent(quoteMessageIds: [id], conversationId: conversationId, from: database) + try PinMessageDAO.shared.delete(messageIds: [id], conversationId: conversationId, from: database) + try clearPinMessageContent(quoteMessageIds: [id], conversationId: conversationId, from: database) + if deleteTranscriptChildren { + try TranscriptMessage + .filter(TranscriptMessage.column(of: .transcriptId) == id) + .deleteAll(database) } return (deleteCount > 0, childMessageIds) } + @discardableResult + public func delete(id: String, conversationId: String, deleteTranscriptChildren: Bool, alongsideTransaction work: ((GRDB.Database) -> Void)) { + db.write { db in + try delete(id: id, conversationId: conversationId, deleteTranscriptChildren: deleteTranscriptChildren, database: db) + work(db) + } + } + + public func deleteLegacyMessage(with id: String) { + db.write { db in + try Message + .filter(Message.column(of: .messageId) == id) + .deleteAll(db) + try deleteFTSContent(db, messageId: id) + } + } + public func hasSentMessage(inConversationOf conversationId: String) -> Bool { let possibleStatus = [ MessageStatus.SENDING.rawValue, diff --git a/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift index 480c5e3ba5..85004e99b4 100644 --- a/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift @@ -190,4 +190,9 @@ public final class TranscriptMessageDAO: UserDatabaseDAO { } } + public func deleteTranscriptMessages(with transcriptId: String) { + db.delete(TranscriptMessage.self, + where: TranscriptMessage.column(of: .transcriptId) == transcriptId) + } + } diff --git a/MixinServices/MixinServices/Database/User/DAO/WorkDAO.swift b/MixinServices/MixinServices/Database/User/DAO/WorkDAO.swift new file mode 100644 index 0000000000..9807961c58 --- /dev/null +++ b/MixinServices/MixinServices/Database/User/DAO/WorkDAO.swift @@ -0,0 +1,33 @@ +import Foundation +import GRDB + +public class WorkDAO { + + public static let shared = WorkDAO() + + public var db: Database { + UserDatabase.current + } + + public func save(work: PersistedWork) { + db.write { db in + try work.save(db) + } + } + + public func works(with types: [String]) -> [PersistedWork] { + db.select(where: types.contains(PersistedWork.column(of: .type)), + order: [PersistedWork.column(of: .priority).desc]) + } + + public func delete(id: String) { + db.delete(PersistedWork.self, where: PersistedWork.column(of: .id) == id) + } + + public func update(context: Data?, forWorkWith id: String) { + db.update(PersistedWork.self, + assignments: [PersistedWork.column(of: .context).set(to: context)], + where: PersistedWork.column(of: .id) == id) + } + +} diff --git a/MixinServices/MixinServices/Database/User/UserDatabase.swift b/MixinServices/MixinServices/Database/User/UserDatabase.swift index 1f6659b9d8..aaaa84c90d 100644 --- a/MixinServices/MixinServices/Database/User/UserDatabase.swift +++ b/MixinServices/MixinServices/Database/User/UserDatabase.swift @@ -493,6 +493,15 @@ public final class UserDatabase: Database { } } + migrator.registerMigration("work") { db in + try db.create(table: "works") { td in + td.column("id", .text).primaryKey().notNull() + td.column("type", .text).notNull() + td.column("context", .blob) + td.column("priority", .integer).notNull() + } + } + return migrator } diff --git a/MixinServices/MixinServices/Foundation/File Management/AttachmentContainer.swift b/MixinServices/MixinServices/Foundation/File Management/AttachmentContainer.swift index 80272682fa..13ed4c6b82 100644 --- a/MixinServices/MixinServices/Foundation/File Management/AttachmentContainer.swift +++ b/MixinServices/MixinServices/Foundation/File Management/AttachmentContainer.swift @@ -66,22 +66,25 @@ public enum AttachmentContainer { } let url = AttachmentContainer.url(for: category, filename: mediaUrl) try? FileManager.default.removeItem(at: url) + Logger.general.debug(category: "AttachmentContainer", message: "\(url) deleted") if category == .videos { let thumbUrl = AttachmentContainer.videoThumbnailURL(videoFilename: mediaUrl) try? FileManager.default.removeItem(at: thumbUrl) + Logger.general.debug(category: "AttachmentContainer", message: "\(thumbUrl) deleted") } } public static func removeAll(transcriptId: String) { let url = Self.url(transcriptId: transcriptId, filename: nil) try? FileManager.default.removeItem(at: url) + Logger.general.debug(category: "AttachmentContainer", message: "\(url) deleted") } } public extension AttachmentContainer { - enum Category: CaseIterable { + enum Category: String, CaseIterable, Codable { case audios case files diff --git a/MixinServices/MixinServices/Services/WebSocket/Jobs/AttachmentLoadingJob.swift b/MixinServices/MixinServices/Services/WebSocket/Jobs/AttachmentLoadingJob.swift index 0a923f7b26..3c225c897e 100644 --- a/MixinServices/MixinServices/Services/WebSocket/Jobs/AttachmentLoadingJob.swift +++ b/MixinServices/MixinServices/Services/WebSocket/Jobs/AttachmentLoadingJob.swift @@ -24,6 +24,7 @@ open class AttachmentLoadingJob: AsynchronousJob { return } if weakSelf.isCancelled || !LoginManager.shared.isLoggedIn { + Logger.general.debug(category: "AttachmentLoadingJob", message: "\(weakSelf.jobId) is cancelled") weakSelf.finishJob() return } else if let error = error { diff --git a/MixinServices/MixinServices/Services/WebSocket/Service/WebSocketService.swift b/MixinServices/MixinServices/Services/WebSocket/Service/WebSocketService.swift index 87e95b65ec..28a55c58eb 100644 --- a/MixinServices/MixinServices/Services/WebSocket/Service/WebSocketService.swift +++ b/MixinServices/MixinServices/Services/WebSocket/Service/WebSocketService.swift @@ -141,7 +141,7 @@ public class WebSocketService { case let .failure(error): if case .invalidRequestData = error { if let param = message.params, let messageId = param.messageId, messageId != messageId.lowercased() { - MessageDAO.shared.deleteMessage(id: messageId) + MessageDAO.shared.deleteLegacyMessage(with: messageId) JobDAO.shared.removeJob(jobId: message.id) } } diff --git a/MixinServices/MixinServices/Services/Work/DeleteConversationAttachmentWork.swift b/MixinServices/MixinServices/Services/Work/DeleteConversationAttachmentWork.swift new file mode 100644 index 0000000000..56a13a39ec --- /dev/null +++ b/MixinServices/MixinServices/Services/Work/DeleteConversationAttachmentWork.swift @@ -0,0 +1,85 @@ +import Foundation +import GRDB + +public final class DeleteConversationAttachmentWork: Work { + + public struct Attachment: Codable, TableRecord, FetchableRecord { + + public static let databaseTableName = Message.databaseTableName + + let url: String + let category: String + + public init(row: Row) { + url = row["media_url"] + category = row["category"] + } + + } + + private let batchLimit = 50 + + private var attachments: [Attachment] + private var transcriptMessageIds: [String] + + public init(id: String = UUID().uuidString.lowercased(), attachments: [Attachment], transcriptMessageIds: [String]) { + self.attachments = attachments + self.transcriptMessageIds = transcriptMessageIds + super.init(id: id, state: .ready) + } + + public override func main() throws { + Logger.general.debug(category: "DeleteConversationAttachmentWork", message: "[\(id)] Delete \(attachments.count) attachments, \(transcriptMessageIds.count) transcripts") + if !attachments.isEmpty { + repeat { + let items = attachments.suffix(batchLimit) + for item in items { + AttachmentContainer.removeMediaFiles(mediaUrl: item.url, category: item.category) + } + attachments.removeLast(items.count) + updatePersistedContext() + Logger.general.debug(category: "DeleteConversationAttachmentWork", message: "[\(id)] Updated with \(attachments.count) attachments, \(transcriptMessageIds.count) transcripts") + } while !attachments.isEmpty + } + if !transcriptMessageIds.isEmpty { + repeat { + let ids = transcriptMessageIds.suffix(batchLimit) + ids.forEach(AttachmentContainer.removeAll(transcriptId:)) + transcriptMessageIds.removeLast(ids.count) + updatePersistedContext() + Logger.general.debug(category: "DeleteConversationAttachmentWork", message: "[\(id)] Updated with \(attachments.count) attachments, \(transcriptMessageIds.count) transcripts") + } while !transcriptMessageIds.isEmpty + } + } + +} + +extension DeleteConversationAttachmentWork: PersistableWork { + + private struct Context: Codable { + let attachments: [Attachment] + let transcriptMessageIds: [String] + } + + public static let typeIdentifier = "delete_conversation_attachment" + + public var context: Data? { + let context = Context(attachments: attachments, transcriptMessageIds: transcriptMessageIds) + return try? JSONEncoder.default.encode(context) + } + + public var priority: PersistedWork.Priority { + .low + } + + public convenience init(id: String, context: Data?) throws { + guard + let context = context, + let context = try? JSONDecoder.default.decode(Context.self, from: context) + else { + throw PersistableWorkError.invalidContext + } + self.init(id: id, attachments: context.attachments, transcriptMessageIds: context.transcriptMessageIds) + } + +} diff --git a/MixinServices/MixinServices/Services/Work/DeleteMessageAttachmentWork.swift b/MixinServices/MixinServices/Services/Work/DeleteMessageAttachmentWork.swift new file mode 100644 index 0000000000..5cda535f89 --- /dev/null +++ b/MixinServices/MixinServices/Services/Work/DeleteMessageAttachmentWork.swift @@ -0,0 +1,118 @@ +import Foundation +import GRDB +import MixinServices + +public protocol DeletableMessage { + var messageId: String { get } + var conversationId: String { get } + var category: String { get } + var mediaUrl: String? { get } +} + +extension Message: DeletableMessage { + +} + +extension MessageItem: DeletableMessage { + +} + +public final class DeleteMessageAttachmentWork: Work { + + private enum Attachment: Codable { + case media(category: String, filename: String) + case transcript + } + + public static let willDeleteNotification = Notification.Name("one.mixin.services.DeleteMessageAttachmentWork.willDelete") + public static let messageIdUserInfoKey = "mid" + public static let capableMessageCategories: Set = [ + MessageCategory.SIGNAL_IMAGE.rawValue, MessageCategory.PLAIN_IMAGE.rawValue, MessageCategory.ENCRYPTED_IMAGE.rawValue, + MessageCategory.SIGNAL_VIDEO.rawValue, MessageCategory.PLAIN_VIDEO.rawValue, MessageCategory.ENCRYPTED_VIDEO.rawValue, + MessageCategory.SIGNAL_AUDIO.rawValue, MessageCategory.PLAIN_AUDIO.rawValue, MessageCategory.ENCRYPTED_AUDIO.rawValue, + MessageCategory.SIGNAL_DATA.rawValue, MessageCategory.PLAIN_DATA.rawValue, MessageCategory.ENCRYPTED_DATA.rawValue, + MessageCategory.SIGNAL_TRANSCRIPT.rawValue, MessageCategory.PLAIN_TRANSCRIPT.rawValue, MessageCategory.ENCRYPTED_TRANSCRIPT.rawValue, + ] + + private let messageId: String + private let conversationId: String + private let attachment: Attachment? + + public convenience init(message: DeletableMessage) { + let attachment: Attachment? + if MessageCategory.allMediaCategoriesString.contains(message.category), let filename = message.mediaUrl { + attachment = .media(category: message.category, filename: filename) + } else if message.category.hasSuffix("_TRANSCRIPT") { + attachment = .transcript + } else { + attachment = nil + } + self.init(messageId: message.messageId, conversationId: message.conversationId, attachment: attachment) + } + + private init(messageId: String, conversationId: String, attachment: Attachment?) { + self.messageId = messageId + self.conversationId = conversationId + self.attachment = attachment + super.init(id: "delete-message-\(messageId)", state: .ready) + } + + public override func main() throws { + NotificationCenter.default.post(onMainThread: Self.willDeleteNotification, + object: self, + userInfo: [Self.messageIdUserInfoKey: messageId]) + switch attachment { + case let .media(category, filename): + AttachmentContainer.removeMediaFiles(mediaUrl: filename, category: category) + case .transcript: + let transcriptId = messageId + let childMessageIds = TranscriptMessageDAO.shared.childrenMessageIds(transcriptId: transcriptId) + let jobIds = childMessageIds.map { transcriptMessageId in + AttachmentDownloadJob.jobId(transcriptId: transcriptId, messageId: transcriptMessageId) + } + for id in jobIds { + ConcurrentJobQueue.shared.cancelJob(jobId: id) + } + AttachmentContainer.removeAll(transcriptId: transcriptId) + TranscriptMessageDAO.shared.deleteTranscriptMessages(with: transcriptId) + case .none: + break + } + } + +} + +extension DeleteMessageAttachmentWork: PersistableWork { + + private struct Context: Codable { + let messageId: String + let conversationId: String + let attachment: Attachment? + } + + public static let typeIdentifier: String = "delete_message_attachment" + + public var context: Data? { + let context = Context(messageId: messageId, + conversationId: conversationId, + attachment: attachment) + return try? JSONEncoder.default.encode(context) + } + + public var priority: PersistedWork.Priority { + .medium + } + + public convenience init(id: String, context: Data?) throws { + guard + let context = context, + let context = try? JSONDecoder.default.decode(Context.self, from: context) + else { + throw PersistableWorkError.invalidContext + } + self.init(messageId: context.messageId, + conversationId: context.conversationId, + attachment: context.attachment) + } + +} diff --git a/MixinServices/MixinServices/Services/Work/PersistableWork.swift b/MixinServices/MixinServices/Services/Work/PersistableWork.swift new file mode 100644 index 0000000000..5edd1a79c0 --- /dev/null +++ b/MixinServices/MixinServices/Services/Work/PersistableWork.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum PersistableWorkError: Error { + case invalidContext +} + +public protocol PersistableWork: Work { + + static var typeIdentifier: String { get } + + var context: Data? { get } + var priority: PersistedWork.Priority { get } + + init(id: String, context: Data?) throws + + func updatePersistedContext() + +} + +extension PersistableWork { + + public func updatePersistedContext() { + WorkDAO.shared.update(context: context, forWorkWith: id) + } + +} diff --git a/MixinServices/MixinServices/Services/Work/PersistedWork.swift b/MixinServices/MixinServices/Services/Work/PersistedWork.swift new file mode 100644 index 0000000000..558978b2b7 --- /dev/null +++ b/MixinServices/MixinServices/Services/Work/PersistedWork.swift @@ -0,0 +1,41 @@ +import Foundation +import GRDB + +public struct PersistedWork: Codable, DatabaseColumnConvertible, PersistableRecord, MixinFetchableRecord { + + public struct Priority: RawRepresentable, Codable { + + public static let high = Priority(rawValue: 90) + public static let medium = Priority(rawValue: 50) + public static let low = Priority(rawValue: 10) + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + } + + public enum CodingKeys: CodingKey { + case id + case type + case context + case priority + } + + public static let databaseTableName = "works" + + public let id: String + public let type: String + public let context: Data? + public let priority: Priority + + public init(id: String, type: String, context: Data?, priority: Priority) { + self.id = id + self.type = type + self.context = context + self.priority = priority + } + +} diff --git a/MixinServices/MixinServices/Services/Work/Work.swift b/MixinServices/MixinServices/Services/Work/Work.swift new file mode 100644 index 0000000000..aa1234e6ae --- /dev/null +++ b/MixinServices/MixinServices/Services/Work/Work.swift @@ -0,0 +1,146 @@ +import Foundation + +protocol WorkStateMonitor: AnyObject { + func work(_ work: Work, stateDidChangeTo newState: Work.State) +} + +open class Work { + + public let id: String + + private let lock = NSRecursiveLock() + + private var _state: State + + private weak var stateMonitor: WorkStateMonitor? + + public init(id: String, state: State) { + self.id = id + self._state = state + } + + func setStateMonitor(_ monitor: WorkStateMonitor) -> Bool { + lock.lock() + defer { + lock.unlock() + } + if stateMonitor == nil { + stateMonitor = monitor + return true + } else { + return false + } + } + + // Override this if you want precisely control of state + open func start() { + state = .executing + do { + try main() + state = .finished(.success) + } catch { + state = .finished(.failed(error)) + } + } + + // Override this for straight synchronous works + open func main() throws { + + } + + open func cancel() { + state = .finished(.cancelled) + } + +} + +// MARK: - Equatable +extension Work: Equatable { + + public static func == (lhs: Work, rhs: Work) -> Bool { + lhs.id == rhs.id + } + +} + +// MARK: - Hashable +extension Work: Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + +} + +// MARK: - CustomStringConvertible +extension Work: CustomStringConvertible { + + open var description: String { + id + } + +} + +// MARK: - States +extension Work { + + public enum State { + + public enum Result { + case success + case failed(Error) + case cancelled + } + + case preparing + case ready + case executing + case finished(Result) + + } + + public var state: State { + set { + lock.lock() + switch (_state, newValue) { + case (.preparing, .ready), (.ready, .executing), (.ready, .finished), (.executing, .finished): + _state = newValue + stateMonitor?.work(self, stateDidChangeTo: newValue) + default: + assertionFailure("Work's state shouldn't be set to \(newValue) from \(_state)") + } + lock.unlock() + } + get { + lock.lock() + let state = _state + lock.unlock() + return _state + } + } + + public var isReady: Bool { + if case .ready = state { + return true + } else { + return false + } + } + + public var isExecuting: Bool { + if case .executing = state { + return true + } else { + return false + } + } + + public var isFinished: Bool { + if case .finished = state { + return true + } else { + return false + } + } + +} diff --git a/MixinServices/MixinServices/Services/Work/WorkManager.swift b/MixinServices/MixinServices/Services/Work/WorkManager.swift new file mode 100644 index 0000000000..3c6af8f75c --- /dev/null +++ b/MixinServices/MixinServices/Services/Work/WorkManager.swift @@ -0,0 +1,164 @@ +import Foundation +import GRDB + +public class WorkManager { + + private enum Persistence { + case standalone + case alongsideTransaction(GRDB.Database) + } + + public static let general = WorkManager(label: "General", maxConcurrentWorkCount: 6) + + let maxConcurrentWorkCount: Int + let label: StaticString + + private let lock = NSRecursiveLock() + private let dispatchQueue: DispatchQueue + + private var executingWorks: Set = [] + private var pendingWorks: [Work] = [] + + var works: [Work] { + lock.lock() + var works = Array(executingWorks) + works.append(contentsOf: pendingWorks) + lock.unlock() + return works + } + + init(label: StaticString, maxConcurrentWorkCount: Int) { + self.label = label + self.maxConcurrentWorkCount = maxConcurrentWorkCount + let attributes: DispatchQueue.Attributes = maxConcurrentWorkCount == 1 ? [] : .concurrent + dispatchQueue = DispatchQueue(label: "one.mixin.services.WorkManager.\(label)", attributes: attributes) + } + + public func wakeUpPersistedWorks(with types: [PersistableWork.Type], completion: ((WorkManager) -> Void)? = nil) { + dispatchQueue.async { + let keyPairs = types.map { type in + (type.typeIdentifier, type) + } + let types = [String: PersistableWork.Type](uniqueKeysWithValues: keyPairs) + let identifiers = [String](types.keys) + for persisted in WorkDAO.shared.works(with: identifiers) { + guard let Work = types[persisted.type] else { + continue + } + do { + let work = try Work.init(id: persisted.id, context: persisted.context) + self.addWork(work, persistence: .none) + } catch { + Logger.general.error(category: "WorkManager", message: "[\(self.label)] Failed to init \(persisted)") + } + } + completion?(self) + } + } + + public func addWork(_ work: Work) { + addWork(work, persistence: .standalone) + } + + public func addPersistableWork(_ work: PersistableWork, alongsideTransactionWith database: GRDB.Database) { + addWork(work, persistence: .alongsideTransaction(database)) + } + + public func cancelAllWorks() { + Logger.general.debug(category: "WorkManager", message: "[\(label)] Will cancel all works") + works.forEach { $0.cancel() } + } + + public func cancelWork(with id: String) { + guard let work = works.first(where: { $0.id == id }) else { + Logger.general.debug(category: "WorkManager", message: "[\(label)] Cancel \(id) but finds nothing") + return + } + Logger.general.debug(category: "WorkManager", message: "[\(label)] Cancel \(id)") + work.cancel() + } + + private func addWork(_ work: Work, persistence: Persistence?) { + guard work.setStateMonitor(self) else { + assertionFailure("Adding work to multiple manager is not supported") + return + } + lock.lock() + defer { + lock.unlock() + } + let isAlreadyScheduled = executingWorks.contains(work) || pendingWorks.contains(work) + guard !isAlreadyScheduled else { + Logger.general.warn(category: "WorkManager", message: "[\(label)] Add a duplicated work: \(work)") + return + } + if let work = work as? PersistableWork, let persistence = persistence { + let persisted = PersistedWork(id: work.id, + type: type(of: work).typeIdentifier, + context: work.context, + priority: work.priority) + switch persistence { + case .standalone: + WorkDAO.shared.save(work: persisted) + case .alongsideTransaction(let database): + do { + try persisted.save(database) + } catch { + Logger.general.error(category: "WorkManager", message: "[\(label)] Failed to save: \(work), error: \(error)") + } + } + } + if work.isReady, executingWorks.count < maxConcurrentWorkCount { + Logger.general.debug(category: "WorkManager", message: "[\(label)] Start \(work) because of adding to queue") + executingWorks.insert(work) + dispatchQueue.async(execute: work.start) + } else { + Logger.general.debug(category: "WorkManager", message: "[\(label)] Pending \(work)") + pendingWorks.append(work) + } + } + +} + +extension WorkManager: WorkStateMonitor { + + func work(_ work: Work, stateDidChangeTo newState: Work.State) { + switch newState { + case .preparing: + assertionFailure("No way a work becomes preparing") + case .ready: + lock.lock() + if executingWorks.count < maxConcurrentWorkCount, let pendingWorksIndex = pendingWorks.firstIndex(of: work) { + Logger.general.debug(category: "WorkManager", message: "[\(label)] Execute \(work) because of readiness change") + pendingWorks.remove(at: pendingWorksIndex) + executingWorks.insert(work) + lock.unlock() + dispatchQueue.async(execute: work.start) + } else { + lock.unlock() + } + case .executing: + Logger.general.debug(category: "WorkManager", message: "[\(label)] Executing: \(work)") + case .finished(let result): + Logger.general.debug(category: "WorkManager", message: "[\(label)] Finished: \(work)") + if let work = work as? PersistableWork { + WorkDAO.shared.delete(id: work.id) + } + lock.lock() + pendingWorks.removeAll { pendingWork in + pendingWork == work + } + executingWorks.remove(work) + if executingWorks.count < maxConcurrentWorkCount, let index = pendingWorks.firstIndex(where: { $0.isReady }) { + let nextWork = pendingWorks.remove(at: index) + executingWorks.insert(nextWork) + lock.unlock() + Logger.general.debug(category: "WorkManager", message: "[\(label)] Execute \(nextWork) because of another work finished") + dispatchQueue.async(execute: work.start) + } else { + lock.unlock() + } + } + } + +}