// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public extension Notification.Name { static let incomingContactSyncDidComplete = Notification.Name("IncomingContactSyncDidComplete") } public class IncomingContactSyncJobQueue: NSObject { public enum Constants { public static let insertedThreads = "insertedThreads" } private let jobQueueRunner: JobQueueRunner< JobRecordFinderImpl, IncomingContactSyncJobRunnerFactory > public init(db: DB, reachabilityManager: SSKReachabilityManager) { self.jobQueueRunner = JobQueueRunner( canExecuteJobsConcurrently: false, db: db, jobFinder: JobRecordFinderImpl(db: db), jobRunnerFactory: IncomingContactSyncJobRunnerFactory() ) super.init() self.jobQueueRunner.listenForReachabilityChanges(reachabilityManager: reachabilityManager) } public func start(appContext: AppContext) { jobQueueRunner.start(shouldRestartExistingJobs: appContext.isMainApp) } public func add( downloadMetadata: AttachmentDownloads.DownloadMetadata, isComplete: Bool, tx: SDSAnyWriteTransaction ) { let jobRecord = IncomingContactSyncJobRecord( cdnNumber: downloadMetadata.cdnNumber, cdnKey: downloadMetadata.cdnKey, encryptionKey: downloadMetadata.encryptionKey, digest: downloadMetadata.digest, plaintextLength: downloadMetadata.plaintextLength, isCompleteContactSync: isComplete ) jobRecord.anyInsert(transaction: tx) tx.addSyncCompletion { self.jobQueueRunner.addPersistedJob(jobRecord) } } } private class IncomingContactSyncJobRunnerFactory: JobRunnerFactory { func buildRunner() -> IncomingContactSyncJobRunner { return IncomingContactSyncJobRunner() } } private class IncomingContactSyncJobRunner: JobRunner, Dependencies { private enum Constants { static let maxRetries: UInt = 4 } func runJobAttempt(_ jobRecord: IncomingContactSyncJobRecord) async -> JobAttemptResult { return await JobAttemptResult.executeBlockWithDefaultErrorHandler( jobRecord: jobRecord, retryLimit: Constants.maxRetries, db: DependenciesBridge.shared.db, block: { try await _runJob(jobRecord) } ) } func didFinishJob(_ jobRecordId: JobRecord.RowId, result: JobResult) async {} private func _runJob(_ jobRecord: IncomingContactSyncJobRecord) async throws { let fileUrl: URL let legacyAttachmentId: String? switch jobRecord.downloadInfo { case .invalid: owsFailDebug("Invalid contact sync job!") await databaseStorage.awaitableWrite { tx in jobRecord.anyRemove(transaction: tx) } return case .legacy(let attachmentId): guard let attachment = (databaseStorage.read { transaction in return TSAttachment.anyFetch(uniqueId: attachmentId, transaction: transaction) }) else { throw OWSAssertionError("missing attachment") } let attachmentStream: TSAttachmentStream switch attachment { case let attachmentPointer as TSAttachmentPointer: attachmentStream = try await TSAttachmentDownloadManager() .enqueueContactSyncDownload(attachmentPointer: attachmentPointer) case let attachmentStreamValue as TSAttachmentStream: attachmentStream = attachmentStreamValue default: throw OWSAssertionError("unexpected attachment type: \(attachment)") } guard let url = attachmentStream.originalMediaURL else { throw OWSAssertionError("fileUrl was unexpectedly nil") } fileUrl = url legacyAttachmentId = attachmentStream.uniqueId case .transient(let downloadMetadata): fileUrl = try await DependenciesBridge.shared.attachmentDownloadManager.downloadTransientAttachment( metadata: downloadMetadata ).awaitable() legacyAttachmentId = nil } let insertedThreads = try await firstly(on: DispatchQueue.global()) { try self.processContactSync(decryptedFileUrl: fileUrl, isComplete: jobRecord.isCompleteContactSync) }.awaitable() await databaseStorage.awaitableWrite { tx in if let legacyAttachmentId { TSAttachmentStream.anyFetch(uniqueId: legacyAttachmentId, transaction: tx)?.anyRemove(transaction: tx) } jobRecord.anyRemove(transaction: tx) } NotificationCenter.default.post(name: .incomingContactSyncDidComplete, object: self, userInfo: [ IncomingContactSyncJobQueue.Constants.insertedThreads: insertedThreads ]) } // MARK: - Private private func processContactSync( decryptedFileUrl fileUrl: URL, isComplete: Bool ) throws -> [(threadUniqueId: String, sortOrder: UInt32)] { var insertedThreads = [(threadUniqueId: String, sortOrder: UInt32)]() try Data(contentsOf: fileUrl, options: .mappedIfSafe).withUnsafeBytes { bufferPtr in if let baseAddress = bufferPtr.baseAddress, bufferPtr.count > 0 { let pointer = baseAddress.assumingMemoryBound(to: UInt8.self) let inputStream = ChunkedInputStream(forReadingFrom: pointer, count: bufferPtr.count) let contactStream = ContactsInputStream(inputStream: inputStream) // We use batching to avoid long-running write transactions // and to place an upper bound on memory usage. var allPhoneNumbers = [E164]() while try processBatch( contactStream: contactStream, insertedThreads: &insertedThreads, processedPhoneNumbers: &allPhoneNumbers ) {} if isComplete { try pruneContacts(exceptThoseReceivedFromCompleteSync: allPhoneNumbers) } databaseStorage.write { transaction in // Always fire just one identity change notification, rather than potentially // once per contact. It's possible that *no* identities actually changed, // but we have no convenient way to track that. let identityManager = DependenciesBridge.shared.identityManager identityManager.fireIdentityStateChangeNotification(after: transaction.asV2Write) } } } return insertedThreads } // Returns false when there are no more contacts to process. private func processBatch( contactStream: ContactsInputStream, insertedThreads: inout [(threadUniqueId: String, sortOrder: UInt32)], processedPhoneNumbers: inout [E164] ) throws -> Bool { try autoreleasepool { // We use batching to avoid long-running write transactions. guard let contactBatch = try Self.buildBatch(contactStream: contactStream) else { return false } guard !contactBatch.isEmpty else { owsFailDebug("Empty batch.") return false } try databaseStorage.write { tx in for contact in contactBatch { if let phoneNumber = try processContactDetails(contact, insertedThreads: &insertedThreads, tx: tx) { processedPhoneNumbers.append(phoneNumber) } } } return true } } private static func buildBatch(contactStream: ContactsInputStream) throws -> [ContactDetails]? { let batchSize = 8 var contacts = [ContactDetails]() while contacts.count < batchSize, let contact = try contactStream.decodeContact() { contacts.append(contact) } guard !contacts.isEmpty else { return nil } return contacts } private func processContactDetails( _ contactDetails: ContactDetails, insertedThreads: inout [(threadUniqueId: String, sortOrder: UInt32)], tx: SDSAnyWriteTransaction ) throws -> E164? { let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx.asV2Read) else { throw OWSGenericError("Not registered.") } let recipientFetcher = DependenciesBridge.shared.recipientFetcher let recipientManager = DependenciesBridge.shared.recipientManager let recipientMerger = DependenciesBridge.shared.recipientMerger let recipient: SignalRecipient if let aci = contactDetails.aci { recipient = recipientMerger.applyMergeFromContactSync( localIdentifiers: localIdentifiers, aci: aci, phoneNumber: contactDetails.phoneNumber, tx: tx.asV2Write ) // Mark as registered only if we have a UUID (we always do in this branch). // If we don't have a UUID, contacts can't be registered. recipientManager.markAsRegisteredAndSave(recipient, shouldUpdateStorageService: false, tx: tx.asV2Write) } else if let phoneNumber = contactDetails.phoneNumber { recipient = recipientFetcher.fetchOrCreate(phoneNumber: phoneNumber, tx: tx.asV2Write) } else { throw OWSAssertionError("No identifier in ContactDetails.") } let address = recipient.address let contactThread: TSContactThread let isNewThread: Bool if let existingThread = TSContactThread.getWithContactAddress(address, transaction: tx) { contactThread = existingThread isNewThread = false } else { let newThread = TSContactThread(contactAddress: address) newThread.shouldThreadBeVisible = true contactThread = newThread isNewThread = true } if isNewThread { contactThread.anyInsert(transaction: tx) let inboxSortOrder = contactDetails.inboxSortOrder ?? UInt32.max insertedThreads.append((threadUniqueId: contactThread.uniqueId, sortOrder: inboxSortOrder)) } let disappearingMessageToken = DisappearingMessageToken.token(forProtoExpireTimerSeconds: contactDetails.expireTimer) GroupManager.remoteUpdateDisappearingMessages( withContactThread: contactThread, disappearingMessageToken: disappearingMessageToken, changeAuthor: nil, localIdentifiers: LocalIdentifiersObjC(localIdentifiers), transaction: tx ) return contactDetails.phoneNumber } /// Clear ``SignalAccount``s that weren't part of a complete sync. /// /// Although "system contact" details (represented by a ``SignalAccount``) /// are synced via StorageService rather than contact sync messages, any /// contacts not included in a complete contact sync are not present on the /// primary device and should there be removed from this linked device. /// /// In theory, StorageService updates should handle removing these contacts. /// However, there's no periodic sync check our state against /// StorageService, so this job continues to fulfill that role. In the /// future, if you're removing this method, you should first ensure that /// periodic full syncs of contact details happen with StorageService. private func pruneContacts(exceptThoseReceivedFromCompleteSync phoneNumbers: [E164]) throws { try self.databaseStorage.write { transaction in // Every contact sync includes your own address. However, we shouldn't // create a SignalAccount for your own address. (If you're a primary, this // is handled by FetchedSystemContacts.phoneNumbers(…).) let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else { throw OWSGenericError("Not registered.") } let setOfPhoneNumbers = Set(phoneNumbers.lazy.filter { !localIdentifiers.contains(phoneNumber: $0) }) // Rather than collecting SignalAccount objects, collect their unique IDs. // This operation can run in the memory-constrainted NSE, so trade off a // bit of speed to save memory. var uniqueIdsToRemove = [String]() SignalAccount.anyEnumerate(transaction: transaction, batchingPreference: .batched(8)) { signalAccount, _ in if let phoneNumber = E164(signalAccount.recipientPhoneNumber), setOfPhoneNumbers.contains(phoneNumber) { // This contact was received in this batch, so don't remove it. return } uniqueIdsToRemove.append(signalAccount.uniqueId) } Logger.info("Removing \(uniqueIdsToRemove.count) contacts during contact sync") for uniqueId in uniqueIdsToRemove { autoreleasepool { guard let signalAccount = SignalAccount.anyFetch(uniqueId: uniqueId, transaction: transaction) else { return } signalAccount.anyRemove(transaction: transaction) } } if !uniqueIdsToRemove.isEmpty { contactsManagerImpl.didUpdateSignalAccounts(transaction: transaction) } } } }