// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Contacts import Foundation import LibSignalClient extension Notification.Name { public static let syncManagerConfigurationSyncDidComplete = Notification.Name("OWSSyncManagerConfigurationSyncDidCompleteNotification") public static let syncManagerKeysSyncDidComplete = Notification.Name("OWSSyncManagerKeysSyncDidCompleteNotification") } @objc public class OWSSyncManager: NSObject, SyncManagerProtocolObjc { private static var keyValueStore: SDSKeyValueStore { SDSKeyValueStore(collection: "kTSStorageManagerOWSSyncManagerCollection") } private var isRequestInFlight: Bool = false public init(default: Void) { super.init() SwiftSingletons.register(self) AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { self.addObservers() if TSAccountManagerObjcBridge.isRegisteredWithMaybeTransaction { if TSAccountManagerObjcBridge.isPrimaryDeviceWithMaybeTransaction { // syncAllContactsIfNecessary will skip if nothing has changed, // so this won't yield redundant traffic. self.syncAllContactsIfNecessary() } else { self.sendAllSyncRequestMessagesIfNecessary().catch { (_ error: Error) in Logger.error("Error: \(error).") } } } } } private func addObservers() { NotificationCenter.default.addObserver(self, selector: #selector(signalAccountsDidChange(_:)), name: .OWSContactsManagerSignalAccountsDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(profileKeyDidChange(_:)), name: UserProfileNotifications.localProfileKeyDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(registrationStateDidChange(_:)), name: RegistrationStateChangeNotifications.registrationStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: .OWSApplicationWillEnterForeground, object: nil) } // MARK: - Notifications @objc private func signalAccountsDidChange(_ notification: AnyObject) { AssertIsOnMainThread() syncAllContactsIfNecessary() } @objc private func profileKeyDidChange(_ notification: AnyObject) { AssertIsOnMainThread() syncAllContactsIfNecessary() } @objc private func registrationStateDidChange(_ notification: AnyObject) { AssertIsOnMainThread() syncAllContactsIfNecessary() } @objc private func willEnterForeground(_ notification: AnyObject) { AssertIsOnMainThread() _ = syncAllContactsIfFullSyncRequested() } // MARK: - SyncManagerProtocolObjc methods public func processIncomingConfigurationSyncMessage(_ syncMessage: SSKProtoSyncMessageConfiguration, transaction: SDSAnyWriteTransaction) { if syncMessage.hasReadReceipts { SSKEnvironment.shared.receiptManager.setAreReadReceiptsEnabled(syncMessage.readReceipts, transaction: transaction) } if syncMessage.hasUnidentifiedDeliveryIndicators { let updatedValue = syncMessage.unidentifiedDeliveryIndicators self.preferences.setShouldShowUnidentifiedDeliveryIndicators(updatedValue, transaction: transaction) } if syncMessage.hasTypingIndicators { self.typingIndicatorsImpl.setTypingIndicatorsEnabled(value: syncMessage.typingIndicators, transaction: transaction) } if syncMessage.hasLinkPreviews { SSKPreferences.setAreLinkPreviewsEnabled(syncMessage.linkPreviews, transaction: transaction) } transaction.addAsyncCompletionOffMain { NotificationCenter.default.postNotificationNameAsync(.syncManagerConfigurationSyncDidComplete, object: nil) } } public func processIncomingContactsSyncMessage(_ syncMessage: SSKProtoSyncMessageContacts, transaction: SDSAnyWriteTransaction) { guard syncMessage.blob.hasCdnNumber, let cdnKey = syncMessage.blob.cdnKey?.nilIfEmpty, let encryptionKey = syncMessage.blob.key?.nilIfEmpty, let digest = syncMessage.blob.digest?.nilIfEmpty, syncMessage.blob.hasSize else { owsFailDebug("failed to create attachment download info from incoming contacts sync message") return } self.smJobQueues.incomingContactSyncJobQueue.add( downloadMetadata: .init( cdnNumber: syncMessage.blob.cdnNumber, cdnKey: cdnKey, encryptionKey: encryptionKey, digest: digest, plaintextLength: syncMessage.blob.size ), isComplete: syncMessage.isComplete, tx: transaction ) } } extension OWSSyncManager: SyncManagerProtocol, SyncManagerProtocolSwift { // MARK: - Constants private enum Constants { static let lastContactSyncKey = "kTSStorageManagerOWSSyncManagerLastMessageKey" static let fullSyncRequestIdKey = "FullSyncRequestId" static let syncRequestedAppVersionKey = "SyncRequestedAppVersion" } // MARK: - Sync Requests @objc public func sendAllSyncRequestMessagesIfNecessary() -> AnyPromise { return AnyPromise(_sendAllSyncRequestMessages(onlyIfNecessary: true)) } @objc public func sendAllSyncRequestMessages(timeout: TimeInterval) -> AnyPromise { return AnyPromise(_sendAllSyncRequestMessages(onlyIfNecessary: false) .timeout(seconds: timeout, substituteValue: ())) } private func _sendAllSyncRequestMessages(onlyIfNecessary: Bool) -> Promise { guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return Promise(error: OWSAssertionError("Unexpectedly tried to send sync request before registration.")) } return databaseStorage.write(.promise) { (transaction) -> Promise in let currentAppVersion = AppVersionImpl.shared.currentAppVersion let syncRequestedAppVersion = { Self.keyValueStore.getString( Constants.syncRequestedAppVersionKey, transaction: transaction ) } // If we don't need to send sync messages, don't send them. if onlyIfNecessary, currentAppVersion == syncRequestedAppVersion() { return .value(()) } // Otherwise, send them & mark that we sent them for this app version. self.sendSyncRequestMessage(.blocked, transaction: transaction) self.sendSyncRequestMessage(.configuration, transaction: transaction) self.sendSyncRequestMessage(.contacts, transaction: transaction) self.sendSyncRequestMessage(.keys, transaction: transaction) Self.keyValueStore.setString( currentAppVersion, key: Constants.syncRequestedAppVersionKey, transaction: transaction ) return Promise.when(fulfilled: [ NotificationCenter.default.observe(once: .incomingContactSyncDidComplete).asVoid(), NotificationCenter.default.observe(once: .syncManagerConfigurationSyncDidComplete).asVoid(), NotificationCenter.default.observe(once: BlockingManager.blockedSyncDidComplete).asVoid(), NotificationCenter.default.observe(once: .syncManagerKeysSyncDidComplete).asVoid() ]) }.then(on: DependenciesBridge.shared.schedulers.sync) { $0 } } public func sendKeysSyncMessage() { Logger.info("") guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return owsFailDebug("Unexpectedly tried to send sync request before registration.") } guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice ?? false else { return owsFailDebug("Keys sync should only be initiated from the primary device") } databaseStorage.asyncWrite { [weak self] transaction in self?.sendKeysSyncMessage(tx: transaction) } } public func sendKeysSyncMessage(tx: SDSAnyWriteTransaction) { Logger.info("") guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx.asV2Write).isRegisteredPrimaryDevice else { return owsFailDebug("Keys sync should only be initiated from the registered primary device") } guard let thread = TSContactThread.getOrCreateLocalThread(transaction: tx) else { return owsFailDebug("Missing thread") } let storageServiceKey = DependenciesBridge.shared.svr.data(for: .storageService, transaction: tx.asV2Read) let masterKey = DependenciesBridge.shared.svr.masterKeyDataForKeysSyncMessage(tx: tx.asV2Read) let syncKeysMessage = OWSSyncKeysMessage( thread: thread, storageServiceKey: storageServiceKey?.rawData, masterKey: masterKey, transaction: tx ) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: syncKeysMessage ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx) } @objc public func processIncomingKeysSyncMessage(_ syncMessage: SSKProtoSyncMessageKeys, transaction: SDSAnyWriteTransaction) { guard !DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegisteredPrimaryDevice else { return owsFailDebug("Key sync messages should only be processed on linked devices") } if let masterKey = syncMessage.master { DependenciesBridge.shared.svr.storeSyncedMasterKey( data: masterKey, authedDevice: .implicit, updateStorageService: true, transaction: transaction.asV2Write ) } else { DependenciesBridge.shared.svr.storeSyncedStorageServiceKey( data: syncMessage.storageService, authedAccount: .implicit(), transaction: transaction.asV2Write ) } transaction.addAsyncCompletionOffMain { NotificationCenter.default.postNotificationNameAsync(.syncManagerKeysSyncDidComplete, object: nil) } } public func sendKeysSyncRequestMessage(transaction: SDSAnyWriteTransaction) { sendSyncRequestMessage(.keys, transaction: transaction) } public func processIncomingFetchLatestSyncMessage( _ syncMessage: SSKProtoSyncMessageFetchLatest, transaction: SDSAnyWriteTransaction ) { switch syncMessage.unwrappedType { case .unknown: owsFailDebug("Unknown fetch latest type") case .localProfile: _ = profileManager.fetchLocalUsersProfile(authedAccount: .implicit()) case .storageManifest: storageServiceManager.restoreOrCreateManifestIfNecessary(authedDevice: .implicit) case .subscriptionStatus: SubscriptionManagerImpl.performDeviceSubscriptionExpiryUpdate() } } @objc public func processIncomingMessageRequestResponseSyncMessage( _ syncMessage: SSKProtoSyncMessageMessageRequestResponse, transaction: SDSAnyWriteTransaction ) { guard let thread: TSThread = { if let groupId = syncMessage.groupID { return TSGroupThread.fetch(groupId: groupId, transaction: transaction) } if let threadAci = Aci.parseFrom(aciString: syncMessage.threadAci) { return TSContactThread.getWithContactAddress(SignalServiceAddress(threadAci), transaction: transaction) } return nil }() else { return owsFailDebug("message request response couldn't find thread") } switch syncMessage.type { case .accept: blockingManager.removeBlockedThread(thread, wasLocallyInitiated: false, transaction: transaction) if let thread = thread as? TSContactThread { /// When we accept a message request on a linked device, /// we unhide the message sender. We will eventually also /// learn about the unhide via a StorageService contact sync, /// since the linked device should mark unhidden in /// StorageService. But it doesn't hurt to get ahead of the /// game and unhide here. DependenciesBridge.shared.recipientHidingManager.removeHiddenRecipient( thread.contactAddress, wasLocallyInitiated: false, tx: transaction.asV2Write ) } profileManager.addThread(toProfileWhitelist: thread, transaction: transaction) case .delete: DependenciesBridge.shared.threadSoftDeleteManager.softDelete( threads: [thread], sendDeleteForMeSyncMessage: false, tx: transaction.asV2Write ) case .block: blockingManager.addBlockedThread(thread, blockMode: .remote, transaction: transaction) case .blockAndDelete: DependenciesBridge.shared.threadSoftDeleteManager.softDelete( threads: [thread], sendDeleteForMeSyncMessage: false, tx: transaction.asV2Write ) blockingManager.addBlockedThread(thread, blockMode: .remote, transaction: transaction) case .spam: TSInfoMessage(thread: thread, messageType: .reportedSpam).anyInsert(transaction: transaction) case .blockAndSpam: blockingManager.addBlockedThread(thread, blockMode: .remote, transaction: transaction) TSInfoMessage(thread: thread, messageType: .reportedSpam).anyInsert(transaction: transaction) case .unknown, .none: owsFailDebug("unexpected message request response type") } } public func sendMessageRequestResponseSyncMessage(thread: TSThread, responseType: OWSSyncMessageRequestResponseType) { Logger.info("") guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return owsFailDebug("Unexpectedly tried to send sync message before registration.") } databaseStorage.asyncWrite { [weak self] transaction in self?.sendMessageRequestResponseSyncMessage(thread: thread, responseType: responseType, transaction: transaction) } } public func sendMessageRequestResponseSyncMessage( thread: TSThread, responseType: OWSSyncMessageRequestResponseType, transaction: SDSAnyWriteTransaction ) { Logger.info("") guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegistered else { return owsFailDebug("Unexpectedly tried to send sync message before registration.") } let syncMessageRequestResponse = OWSSyncMessageRequestResponseMessage(thread: thread, responseType: responseType, transaction: transaction) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: syncMessageRequestResponse ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction) } // MARK: - Configuration Sync public func sendConfigurationSyncMessage() { AppReadiness.runNowOrWhenAppDidBecomeReadyAsync { Task { await self.databaseStorage.awaitableWrite(block: self._sendConfigurationSyncMessage(tx:)) } } } private func _sendConfigurationSyncMessage(tx: SDSAnyWriteTransaction) { Logger.info("") let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard tsAccountManager.registrationState(tx: tx.asV2Read).isRegistered else { return } guard let thread = TSContactThread.getOrCreateLocalThread(transaction: tx) else { owsFailDebug("Missing thread.") return } let linkPreviews = SSKPreferences.areLinkPreviewsEnabled(transaction: tx) let readReceipts = receiptManager.areReadReceiptsEnabled(transaction: tx) let sealedSenderIndicators = preferences.shouldShowUnidentifiedDeliveryIndicators(transaction: tx) let typingIndicators = typingIndicatorsImpl.areTypingIndicatorsEnabled() let configurationSyncMessage = OWSSyncConfigurationMessage( thread: thread, readReceiptsEnabled: readReceipts, showUnidentifiedDeliveryIndicators: sealedSenderIndicators, showTypingIndicators: typingIndicators, sendLinkPreviews: linkPreviews, transaction: tx ) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: configurationSyncMessage ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx) } // MARK: - Contact Sync public func syncAllContacts() -> AnyPromise { owsAssertDebug(canSendContactSyncMessage()) return AnyPromise(syncContacts(mode: .allSignalAccounts)) } @objc func syncAllContactsIfNecessary() { owsAssertDebug(CurrentAppContext().isMainApp) _ = syncContacts(mode: .allSignalAccountsIfChanged) } public func syncAllContactsIfFullSyncRequested() -> AnyPromise { owsAssertDebug(CurrentAppContext().isMainApp) return AnyPromise(syncContacts(mode: .allSignalAccountsIfFullSyncRequested)) } private enum ContactSyncMode { case allSignalAccounts case allSignalAccountsIfChanged case allSignalAccountsIfFullSyncRequested } private func canSendContactSyncMessage() -> Bool { guard AppReadiness.isAppReady else { return false } let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice else { return false } return true } private static let contactSyncQueue = DispatchQueue(label: "org.signal.contact-sync", autoreleaseFrequency: .workItem) private func syncContacts(mode: ContactSyncMode) -> Promise { if DebugFlags.dontSendContactOrGroupSyncMessages.get() { Logger.info("Skipping contact sync message.") return .value(()) } guard canSendContactSyncMessage() else { return Promise(error: OWSGenericError("Not ready to sync contacts.")) } return Promise { future in Self.contactSyncQueue.async { do { future.resolve(on: SyncScheduler(), with: try self._syncContacts(mode: mode)) } catch { future.reject(error) } } } } private func _syncContacts(mode: ContactSyncMode) throws -> Promise { // Don't bother sending sync messages with the same data as the last // successfully sent contact sync message. let opportunistic = mode == .allSignalAccountsIfChanged // Only have one sync message in flight at a time. let debounce = mode == .allSignalAccountsIfChanged if debounce, self.isRequestInFlight { // De-bounce. It's okay if we ignore some new changes; // `syncAllContactsIfNecessary` is called fairly often so we'll sync soon. return .value(()) } if CurrentAppContext().isNSE { // If a full sync is specifically requested in the NSE, mark it so that the // main app can send that request the next time in runs. if mode == .allSignalAccounts { databaseStorage.write { tx in Self.keyValueStore.setString(UUID().uuidString, key: Constants.fullSyncRequestIdKey, transaction: tx) } } // If a full sync sync is requested in NSE, ignore it. Opportunistic syncs // shouldn't be requested, but this guards against cases where they are. return .value(()) } guard let thread = TSContactThread.getOrCreateLocalThreadWithSneakyTransaction() else { owsFailDebug("Missing thread.") throw OWSError(error: .contactSyncFailed, description: "Could not sync contacts.", isRetryable: false) } let result = try databaseStorage.read { tx in try buildContactSyncMessage(in: thread, mode: mode, tx: tx) } guard let result else { return .value(()) } let messageHash: Data do { messageHash = try Cryptography.computeSHA256DigestOfFile(at: result.syncFileUrl) } catch { owsFailDebug("Error: \(error).") throw OWSError(error: .contactSyncFailed, description: "Could not sync contacts.", isRetryable: false) } // If the NSE requested a sync and the main app does an opportunistic sync, // we should send that request since we've been given a strong signal that // someone is waiting to receive this message. if opportunistic, result.fullSyncRequestId == nil, messageHash == result.previousMessageHash { // Ignore redundant contacts sync message. return .value(()) } let dataSource = try DataSourcePath.dataSource(with: result.syncFileUrl, shouldDeleteOnDeallocation: true) if debounce { self.isRequestInFlight = true } return Promise.wrapAsync { defer { if debounce { Self.contactSyncQueue.async { self.isRequestInFlight = false } } } try await self.messageSender.sendTransientContactSyncAttachment(dataSource: dataSource, thread: thread) await self.databaseStorage.awaitableWrite { tx in Self.keyValueStore.setData(messageHash, key: Constants.lastContactSyncKey, transaction: tx) self.clearFullSyncRequestId(ifMatches: result.fullSyncRequestId, tx: tx) } } } private struct BuildContactSyncMessageResult { var syncFileUrl: URL var fullSyncRequestId: String? var previousMessageHash: Data? } private func buildContactSyncMessage( in thread: TSThread, mode: ContactSyncMode, tx: SDSAnyReadTransaction ) throws -> BuildContactSyncMessageResult? { // Check if there's a pending request from the NSE. Any full sync in the // main app can clear this flag, even if it's not started in response to // calling syncAllContactsIfFullSyncRequested. let fullSyncRequestId = Self.keyValueStore.getString(Constants.fullSyncRequestIdKey, transaction: tx) // However, only syncAllContactsIfFullSyncRequested-initiated requests // should be skipped if there's no request. if mode == .allSignalAccountsIfFullSyncRequested, fullSyncRequestId == nil { return nil } guard let syncFileUrl = ContactSyncAttachmentBuilder.buildAttachmentFile( contactsManager: Self.contactsManagerImpl, tx: tx ) else { owsFailDebug("Failed to serialize contacts sync message.") throw OWSError(error: .contactSyncFailed, description: "Could not sync contacts.", isRetryable: false) } return BuildContactSyncMessageResult( syncFileUrl: syncFileUrl, fullSyncRequestId: fullSyncRequestId, previousMessageHash: Self.keyValueStore.getData(Constants.lastContactSyncKey, transaction: tx) ) } private func clearFullSyncRequestId(ifMatches requestId: String?, tx: SDSAnyWriteTransaction) { guard let requestId else { return } let storedRequestId = Self.keyValueStore.getString(Constants.fullSyncRequestIdKey, transaction: tx) // If the requestId we just finished matches the one in the database, we've // fulfilled the contract with the NSE. If the NSE triggers *another* sync // while this is outstanding, the match will fail, and we'll kick off // another sync at the next opportunity. if storedRequestId == requestId { Self.keyValueStore.removeValue(forKey: Constants.fullSyncRequestIdKey, transaction: tx) } } // MARK: - Fetch Latest public func sendFetchLatestProfileSyncMessage(tx: SDSAnyWriteTransaction) { _sendFetchLatestSyncMessage(type: .localProfile, tx: tx) } public func sendFetchLatestStorageManifestSyncMessage() { sendFetchLatestSyncMessage(type: .storageManifest) } public func sendFetchLatestSubscriptionStatusSyncMessage() { sendFetchLatestSyncMessage(type: .subscriptionStatus) } private func sendFetchLatestSyncMessage(type: OWSSyncFetchType) { Task { await self.databaseStorage.awaitableWrite { tx in self._sendFetchLatestSyncMessage(type: type, tx: tx) } } } private func _sendFetchLatestSyncMessage(type: OWSSyncFetchType, tx: SDSAnyWriteTransaction) { let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard tsAccountManager.registrationState(tx: tx.asV2Read).isRegistered else { owsFailDebug("Tried to send sync message before registration.") return } guard let thread = TSContactThread.getOrCreateLocalThread(transaction: tx) else { owsFailDebug("Missing thread.") return } let fetchLatestSyncMessage = OWSSyncFetchLatestMessage(thread: thread, fetchType: type, transaction: tx) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: fetchLatestSyncMessage ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx) } } // MARK: - public extension OWSSyncManager { func sendInitialSyncRequestsAwaitingCreatedThreadOrdering(timeoutSeconds: TimeInterval) -> Promise<[String]> { Logger.info("") guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return Promise(error: OWSAssertionError("Unexpectedly tried to send sync request before registration.")) } databaseStorage.asyncWrite { transaction in self.sendSyncRequestMessage(.blocked, transaction: transaction) self.sendSyncRequestMessage(.configuration, transaction: transaction) self.sendSyncRequestMessage(.contacts, transaction: transaction) } let notificationsPromise: Promise<([(threadUniqueId: String, sortOrder: UInt32)], Void, Void)> = Promise.when(fulfilled: NotificationCenter.default.observe(once: .incomingContactSyncDidComplete).map { $0.insertedThreads }.timeout(seconds: timeoutSeconds, substituteValue: []), NotificationCenter.default.observe(once: .syncManagerConfigurationSyncDidComplete).asVoid().timeout(seconds: timeoutSeconds), NotificationCenter.default.observe(once: BlockingManager.blockedSyncDidComplete).asVoid().timeout(seconds: timeoutSeconds) ) return notificationsPromise.map { (insertedThreads, _, _) -> [String] in return insertedThreads.sorted(by: { $0.sortOrder < $1.sortOrder }).map({ $0.threadUniqueId }) } } @objc fileprivate func sendSyncRequestMessage(_ requestType: SSKProtoSyncMessageRequestType, transaction: SDSAnyWriteTransaction) { switch requestType { case .unknown: owsFailDebug("should not request unknown") case .contacts: Logger.info("contacts") case .blocked: Logger.info("blocked") case .configuration: Logger.info("configuration") case .keys: Logger.info("keys") } guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return owsFailDebug("Unexpectedly tried to send sync request before registration.") } guard !DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice else { return owsFailDebug("Sync request should only be sent from a linked device") } guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else { return owsFailDebug("Missing thread") } let syncRequestMessage = OWSSyncRequestMessage(thread: thread, requestType: requestType.rawValue, transaction: transaction) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: syncRequestMessage ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction) } } // MARK: - private extension Notification { var insertedThreads: [(threadUniqueId: String, sortOrder: UInt32)] { return userInfo?[IncomingContactSyncJobQueue.Constants.insertedThreads] as! [(threadUniqueId: String, sortOrder: UInt32)] } }