// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LibSignalClient public class MessageBackupPostFrameRestoreActionManager { typealias SharedMap = MessageBackup.SharedMap typealias RecipientId = MessageBackup.RecipientId typealias RecipientActions = MessageBackup.RecipientRestoringContext.PostFrameRestoreActions typealias ChatId = MessageBackup.ChatId typealias ChatActions = MessageBackup.ChatRestoringContext.PostFrameRestoreActions private let avatarFetcher: MessageBackupAvatarFetcher private let dateProvider: DateProvider private let interactionStore: MessageBackupInteractionStore private let lastVisibleInteractionStore: LastVisibleInteractionStore private let preferences: MessageBackup.Shims.Preferences private let recipientDatabaseTable: RecipientDatabaseTable private let sskPreferences: MessageBackup.Shims.SSKPreferences private let threadStore: MessageBackupThreadStore init( avatarFetcher: MessageBackupAvatarFetcher, dateProvider: @escaping DateProvider, interactionStore: MessageBackupInteractionStore, lastVisibleInteractionStore: LastVisibleInteractionStore, preferences: MessageBackup.Shims.Preferences, recipientDatabaseTable: RecipientDatabaseTable, sskPreferences: MessageBackup.Shims.SSKPreferences, threadStore: MessageBackupThreadStore ) { self.avatarFetcher = avatarFetcher self.dateProvider = dateProvider self.interactionStore = interactionStore self.lastVisibleInteractionStore = lastVisibleInteractionStore self.preferences = preferences self.recipientDatabaseTable = recipientDatabaseTable self.sskPreferences = sskPreferences self.threadStore = threadStore } // MARK: - func performPostFrameRestoreActions( recipientActions: SharedMap, chatActions: SharedMap, bencher: MessageBackup.RestoreBencher, chatItemContext: MessageBackup.ChatItemRestoringContext ) throws { // Proactively mark the group call tooltip shown; we don't know // definitively if it was shown prior to restore, but it's a good // guess that it was and its annoying to see again. preferences.setWasGroupCallTooltipShown(tx: chatItemContext.tx) for (recipientId, actions) in recipientActions { if actions.insertContactHiddenInfoMessage { try bencher.benchPostFrameRestoreAction(.InsertContactHiddenInfoMessage) { try insertContactHiddenInfoMessage(recipientId: recipientId, chatItemContext: chatItemContext) } } if actions.hasIncomingMessagesMissingAci { bencher.benchPostFrameRestoreAction(.InsertPhoneNumberMissingAci) { insertPhoneNumberMissingAci(recipientId: recipientId, chatItemContext: chatItemContext) } } } // Note: This should happen after recipient actions; the recipient actions insert // messages which may themselves influence the set of chat actions. // (At time of writing, ordering is irrelevant, because hiding info messages aren't "visible". // But ordering requirements could change in the future). var wasAnyThreadVisible = false for (chatId, actions) in chatActions { guard let thread = chatItemContext.chatContext[chatId] else { continue } try bencher.benchPostFrameRestoreAction(.UpdateThreadMetadata) { if actions.shouldBeMarkedVisible { wasAnyThreadVisible = true try threadStore.markVisible( thread: thread, lastInteractionRowId: actions.lastVisibleInteractionRowId, context: chatItemContext.chatContext ) } if let lastVisibleInteractionRowId = actions.lastVisibleInteractionRowId, let lastVisibleInteractionRowId = UInt64(exactly: lastVisibleInteractionRowId), !actions.hadAnyUnreadMessages { // If we had no unread messages but we have some message, // set that as the last visible message so that thats what // we scroll to. lastVisibleInteractionStore.setLastVisibleInteraction( TSThread.LastVisibleInteraction( sortId: lastVisibleInteractionRowId, onScreenPercentage: 1 ), for: thread.tsThread, tx: chatItemContext.tx ) } switch thread.threadType { case .contact: break case .groupV2(let groupThread): try updateLastInteractionTimestamps( for: groupThread, actions: actions, context: chatItemContext.chatContext ) } } } if wasAnyThreadVisible { sskPreferences.setHasSavedThread(true, tx: chatItemContext.tx) } let avatarFetchTimestamp = dateProvider().ows_millisecondsSince1970 for recipientId in chatItemContext.recipientContext.allRecipientIds() { guard let recipientAddress = chatItemContext.recipientContext[recipientId] else { continue } func getLastVisibleInteractionRowId() -> Int64? { guard let chatId = chatItemContext.chatContext[recipientId], let action = chatActions[chatId] else { return nil } return action.lastVisibleInteractionRowId } try bencher.benchPostFrameRestoreAction(.EnqueueAvatarFetch) { switch recipientAddress { case .releaseNotesChannel, .distributionList, .callLink: return case .localAddress: try avatarFetcher.enqueueFetchOfUserProfile( serviceId: chatItemContext.recipientContext.localIdentifiers.aci, currentTimestamp: avatarFetchTimestamp, lastVisibleInteractionRowIdInContactThread: getLastVisibleInteractionRowId(), localIdentifiers: chatItemContext.recipientContext.localIdentifiers, tx: chatItemContext.tx ) case .contact(let contactAddress): guard let serviceId: ServiceId = contactAddress.aci ?? contactAddress.pni else { return } try avatarFetcher.enqueueFetchOfUserProfile( serviceId: serviceId, currentTimestamp: avatarFetchTimestamp, lastVisibleInteractionRowIdInContactThread: getLastVisibleInteractionRowId(), localIdentifiers: chatItemContext.recipientContext.localIdentifiers, tx: chatItemContext.tx ) case .group(let groupId): guard let groupThread = chatItemContext.recipientContext[groupId] else { return } try avatarFetcher.enqueueFetchOfGroupAvatar( groupThread, currentTimestamp: avatarFetchTimestamp, lastVisibleInteractionRowIdInGroupThread: getLastVisibleInteractionRowId(), localIdentifiers: chatItemContext.recipientContext.localIdentifiers, tx: chatItemContext.tx ) } } } } /// Inserts a `TSInfoMessage` that a contact was hidden, for the given /// `SignalRecipient` SQLite row ID. private func insertContactHiddenInfoMessage( recipientId: MessageBackup.RecipientId, chatItemContext: MessageBackup.ChatItemRestoringContext ) throws { guard let chatId = chatItemContext.chatContext[recipientId], let chatThread = chatItemContext.chatContext[chatId], case let .contact(contactThread) = chatThread.threadType else { /// This is weird, because we shouldn't be able to hide a recipient /// without a chat existing. However, who's to say what we'll import /// and it's not illegal to create a Backup with a `Contact` frame /// that doesn't have a corresponding `Chat` frame. Logger.warn("Skipping insert of contact-hidden info message: missing contact thread for recipient!") return } let infoMessage: TSInfoMessage = .makeForContactHidden(contactThread: contactThread) try interactionStore.insert( infoMessage, in: chatThread, chatId: chatId, // This info message is directionless directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()), context: chatItemContext ) } private func insertPhoneNumberMissingAci(recipientId: MessageBackup.RecipientId, chatItemContext: MessageBackup.ChatItemRestoringContext) { guard let address = chatItemContext.recipientContext[recipientId], case .contact(let contactAddress) = address, let phoneNumber = contactAddress.e164 else { Logger.warn("Skipping insert of phone number missing ACI because there's no phone number!") return } AuthorMergeHelper().foundMissingAci(for: phoneNumber.stringValue, tx: chatItemContext.tx) } private func updateLastInteractionTimestamps( for groupThread: TSGroupThread, actions: ChatActions, context: MessageBackup.ChatRestoringContext ) throws { for memberAddress in groupThread.groupMembership.fullMembers { guard let memberAci = memberAddress.aci else { // We only restore v2 groups which always have acis for // full group members. throw OWSAssertionError("Non aci group member in backup!") } guard let latestTimestamp = actions.groupMemberLastInteractionTimestamp[memberAci] else { continue } let groupMember = try TSGroupMember.groupMember( for: memberAci, in: groupThread, tx: context.tx ) try groupMember?.updateWith( lastInteractionTimestamp: latestTimestamp, tx: context.tx ) } } }