diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index af34d835cc..92d6da4283 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -2740,6 +2740,7 @@ D943F3EF2892F89B008C0C8B /* NSELogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D943F3EE2892F89B008C0C8B /* NSELogger.swift */; }; D94441312D55956B005B2A54 /* UUIDv7.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94441302D559567005B2A54 /* UUIDv7.swift */; }; D94441332D559C6F005B2A54 /* UUIDv7Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94441322D559C6B005B2A54 /* UUIDv7Test.swift */; }; + D944D2FD2FBCE9C00026FD21 /* OWSDecryptionPlaceholderExpirationJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = D944D2FC2FBCE9C00026FD21 /* OWSDecryptionPlaceholderExpirationJob.swift */; }; D945319E2CE53CEB004DAB30 /* SubscriptionRedemptionNecessityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D945319D2CE53CC8004DAB30 /* SubscriptionRedemptionNecessityChecker.swift */; }; D94852272F6A224000B130B2 /* GroupCallVideoContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94852262F6A223500B130B2 /* GroupCallVideoContextMenuConfiguration.swift */; }; D9495A6D2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9495A6C2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift */; }; @@ -7018,6 +7019,7 @@ D943F3EE2892F89B008C0C8B /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = ""; }; D94441302D559567005B2A54 /* UUIDv7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDv7.swift; sourceTree = ""; }; D94441322D559C6B005B2A54 /* UUIDv7Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDv7Test.swift; sourceTree = ""; }; + D944D2FC2FBCE9C00026FD21 /* OWSDecryptionPlaceholderExpirationJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDecryptionPlaceholderExpirationJob.swift; sourceTree = ""; }; D945319D2CE53CC8004DAB30 /* SubscriptionRedemptionNecessityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRedemptionNecessityChecker.swift; sourceTree = ""; }; D94852262F6A223500B130B2 /* GroupCallVideoContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallVideoContextMenuConfiguration.swift; sourceTree = ""; }; D9495A6C2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSOutgoingMessageRecipientState.swift; sourceTree = ""; }; @@ -14713,6 +14715,7 @@ F9C5C950289453B100548EEE /* OWSAddToProfileWhitelistOfferMessage+SDS.swift */, F9C5C997289453B100548EEE /* OWSAddToProfileWhitelistOfferMessage.h */, F9C5C958289453B100548EEE /* OWSAddToProfileWhitelistOfferMessage.m */, + D944D2FC2FBCE9C00026FD21 /* OWSDecryptionPlaceholderExpirationJob.swift */, F9C5C93B289453B100548EEE /* OWSIdentityManager.swift */, F9C5C983289453B100548EEE /* OWSMessageDecrypter.swift */, F9C5C973289453B100548EEE /* OWSMessageSend.swift */, @@ -19515,6 +19518,7 @@ 66D31DA92BC48D7900EAF735 /* OWSContactPhoneNumber.swift in Sources */, 725465192BA00F7500EABFD2 /* OWSContactsManager.swift in Sources */, 72328C892C6C6733000EA728 /* OWSCountryMetadata.swift in Sources */, + D944D2FD2FBCE9C00026FD21 /* OWSDecryptionPlaceholderExpirationJob.swift in Sources */, F9C5CCFD289453B300548EEE /* OWSDevice.swift in Sources */, D92AB7D829E3BEE30081CA7D /* OWSDeviceManager.swift in Sources */, F9C5CE0C289453B400548EEE /* OWSDeviceNames.swift in Sources */, diff --git a/Signal/AppLaunch/AppDelegate.swift b/Signal/AppLaunch/AppDelegate.swift index 156bc8683a..a1745758e3 100644 --- a/Signal/AppLaunch/AppDelegate.swift +++ b/Signal/AppLaunch/AppDelegate.swift @@ -716,6 +716,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // element" should call .restart() on the appropriate job. dependenciesBridge.deletedCallRecordExpirationJob.start() dependenciesBridge.disappearingMessagesExpirationJob.start() + dependenciesBridge.decryptionPlaceholderExpirationJob.start() dependenciesBridge.storyMessageExpirationJob.start() dependenciesBridge.pinnedMessageExpirationJob.start() diff --git a/SignalServiceKit/Environment/AppSetup.swift b/SignalServiceKit/Environment/AppSetup.swift index 656829388e..ef4ae375a8 100644 --- a/SignalServiceKit/Environment/AppSetup.swift +++ b/SignalServiceKit/Environment/AppSetup.swift @@ -864,6 +864,13 @@ extension AppSetup.GlobalsContinuation { db: db, interactionDeleteManager: interactionDeleteManager, ) + let decryptionPlaceholderExpirationJob = OWSDecryptionPlaceholderExpirationJob( + dateProvider: dateProvider, + db: db, + interactionDeleteManager: interactionDeleteManager, + messageTimestampGenerator: .sharedInstance, + notificationPresenter: notificationPresenter, + ) let callRecordDeleteAllJobQueue = CallRecordDeleteAllJobQueue( callLinkStore: callLinkStore, @@ -1727,6 +1734,7 @@ extension AppSetup.GlobalsContinuation { currentCallProvider: currentCallProvider, databaseChangeObserver: databaseStorage.databaseChangeObserver, db: db, + decryptionPlaceholderExpirationJob: decryptionPlaceholderExpirationJob, deletedCallRecordExpirationJob: deletedCallRecordExpirationJob, deletedCallRecordStore: deletedCallRecordStore, deleteForMeIncomingSyncMessageManager: deleteForMeIncomingSyncMessageManager, diff --git a/SignalServiceKit/Environment/BuildFlags.swift b/SignalServiceKit/Environment/BuildFlags.swift index d442ce5aee..9b22fba994 100644 --- a/SignalServiceKit/Environment/BuildFlags.swift +++ b/SignalServiceKit/Environment/BuildFlags.swift @@ -196,7 +196,7 @@ public enum DebugFlags { title: LocalizationNotNeeded("Early placeholder expiration"), details: LocalizationNotNeeded("Shortens the valid window for message resend+recovery."), toggleHandler: { _ in - SSKEnvironment.shared.messageDecrypterRef.cleanUpExpiredPlaceholders() + DependenciesBridge.shared.decryptionPlaceholderExpirationJob.restart() }, ) diff --git a/SignalServiceKit/Environment/DependenciesBridge.swift b/SignalServiceKit/Environment/DependenciesBridge.swift index ef1a97f17f..d4935fbc46 100644 --- a/SignalServiceKit/Environment/DependenciesBridge.swift +++ b/SignalServiceKit/Environment/DependenciesBridge.swift @@ -99,6 +99,7 @@ public class DependenciesBridge { public let currentCallProvider: any CurrentCallProvider public let databaseChangeObserver: DatabaseChangeObserver public let db: any DB + public let decryptionPlaceholderExpirationJob: OWSDecryptionPlaceholderExpirationJob public let deletedCallRecordExpirationJob: DeletedCallRecordExpirationJob let deletedCallRecordStore: DeletedCallRecordStore let deleteForMeIncomingSyncMessageManager: DeleteForMeIncomingSyncMessageManager @@ -242,6 +243,7 @@ public class DependenciesBridge { currentCallProvider: any CurrentCallProvider, databaseChangeObserver: DatabaseChangeObserver, db: any DB, + decryptionPlaceholderExpirationJob: OWSDecryptionPlaceholderExpirationJob, deletedCallRecordExpirationJob: DeletedCallRecordExpirationJob, deletedCallRecordStore: DeletedCallRecordStore, deleteForMeIncomingSyncMessageManager: DeleteForMeIncomingSyncMessageManager, @@ -384,6 +386,7 @@ public class DependenciesBridge { self.currentCallProvider = currentCallProvider self.databaseChangeObserver = databaseChangeObserver self.db = db + self.decryptionPlaceholderExpirationJob = decryptionPlaceholderExpirationJob self.deletedCallRecordExpirationJob = deletedCallRecordExpirationJob self.deletedCallRecordStore = deletedCallRecordStore self.deleteForMeIncomingSyncMessageManager = deleteForMeIncomingSyncMessageManager diff --git a/SignalServiceKit/Expiration/ExpirationJob.swift b/SignalServiceKit/Expiration/ExpirationJob.swift index f7dc577015..71b64b7bcf 100644 --- a/SignalServiceKit/Expiration/ExpirationJob.swift +++ b/SignalServiceKit/Expiration/ExpirationJob.swift @@ -21,10 +21,11 @@ open class ExpirationJob { private let dateProvider: DateProvider private let db: DB - private let logger: PrefixedLogger private let minIntervalBetweenDeletes: TimeInterval private let testHooks: TestHooks? + public let logger: PrefixedLogger + private struct State { var notificationObservers: [NotificationCenter.Observer] = [] diff --git a/SignalServiceKit/Messages/OWSDecryptionPlaceholderExpirationJob.swift b/SignalServiceKit/Messages/OWSDecryptionPlaceholderExpirationJob.swift new file mode 100644 index 0000000000..a335e19fd8 --- /dev/null +++ b/SignalServiceKit/Messages/OWSDecryptionPlaceholderExpirationJob.swift @@ -0,0 +1,55 @@ +// +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +public final class OWSDecryptionPlaceholderExpirationJob: ExpirationJob { + private let interactionDeleteManager: InteractionDeleteManager + private let messageTimestampGenerator: MessageTimestampGenerator + private let notificationPresenter: NotificationPresenter + + init( + dateProvider: @escaping DateProvider, + db: DB, + interactionDeleteManager: InteractionDeleteManager, + messageTimestampGenerator: MessageTimestampGenerator, + notificationPresenter: NotificationPresenter, + ) { + self.interactionDeleteManager = interactionDeleteManager + self.messageTimestampGenerator = messageTimestampGenerator + self.notificationPresenter = notificationPresenter + + super.init( + dateProvider: dateProvider, + db: db, + logger: PrefixedLogger(prefix: "[DecryptionPlaceholderExpJob]"), + ) + } + + override public func nextExpiringElement(tx: DBReadTransaction) -> OWSRecoverableDecryptionPlaceholder? { + return InteractionFinder.nextExpiringPlaceholder(tx: tx) + } + + override public func expirationDate(ofElement placeholder: OWSRecoverableDecryptionPlaceholder) -> Date { + return placeholder.expirationDate + } + + override public func deleteExpiredElement(_ placeholder: OWSRecoverableDecryptionPlaceholder, tx: DBWriteTransaction) { + logger.warn("Replacing decryption placeholder \(placeholder.timestamp) with error.") + + interactionDeleteManager.delete(placeholder, sideEffects: .default(), tx: tx) + + guard let thread = placeholder.thread(tx: tx) else { + return + } + + let errorMessage: TSErrorMessage = .failedDecryption( + thread: thread, + timestamp: messageTimestampGenerator.generateTimestamp(), + sender: placeholder.sender, + ) + errorMessage.anyInsert(transaction: tx) + + notificationPresenter.notifyUser(forErrorMessage: errorMessage, thread: thread, transaction: tx) + } +} diff --git a/SignalServiceKit/Messages/OWSMessageDecrypter.swift b/SignalServiceKit/Messages/OWSMessageDecrypter.swift index 0dcf200200..1b0eaf3aa2 100644 --- a/SignalServiceKit/Messages/OWSMessageDecrypter.swift +++ b/SignalServiceKit/Messages/OWSMessageDecrypter.swift @@ -9,10 +9,6 @@ public class OWSMessageDecrypter { private let senderIdsResetDuringCurrentBatch = AtomicValue>(Set(), lock: .init()) - private var placeholderCleanupTimer: Timer? { - didSet { oldValue?.invalidate() } - } - public init(appReadiness: AppReadiness) { SwiftSingletons.register(self) @@ -24,12 +20,6 @@ public class OWSMessageDecrypter { object: nil, ) } - - appReadiness.runNowOrWhenAppDidBecomeReadyAsync { [weak self] in - guard let self else { return } - guard CurrentAppContext().isMainApp else { return } - self.cleanUpExpiredPlaceholders() - } } @objc @@ -232,8 +222,8 @@ public class OWSMessageDecrypter { untrustedGroupId: unsealedEnvelope.untrustedGroupId, transaction: transaction, ) - if let recoverableErrorMessage { - schedulePlaceholderCleanupIfNecessary(for: recoverableErrorMessage) + if recoverableErrorMessage != nil { + DependenciesBridge.shared.decryptionPlaceholderExpirationJob.restart() } errorMessage = recoverableErrorMessage case .implicit: @@ -708,83 +698,6 @@ public class OWSMessageDecrypter { } } - private func schedulePlaceholderCleanupIfNecessary(for placeholder: OWSRecoverableDecryptionPlaceholder) { - DispatchQueue.main.async { - self.schedulePlaceholderCleanup(noLaterThan: placeholder.expirationDate) - } - } - - private func schedulePlaceholderCleanup(noLaterThan expirationDate: Date) { - let fireDate = placeholderCleanupTimer?.fireDate ?? .distantFuture - // Only change the fireDate if it's changed "enough", where we consider - // about 5 seconds of leeway sufficient. - let latestAcceptableFireDate = expirationDate.addingTimeInterval(5) - - if latestAcceptableFireDate < fireDate { - placeholderCleanupTimer = Timer.scheduledTimer( - withTimeInterval: expirationDate.timeIntervalSinceNow, - repeats: false, - block: { [weak self] _ in self?.cleanUpExpiredPlaceholders() }, - ) - } - } - - func cleanUpExpiredPlaceholders() { - Task { await self._cleanUpExpiredPlaceholders() } - } - - private func _cleanUpExpiredPlaceholders() async { - let (expiredPlaceholderIds, nextExpirationDate) = SSKEnvironment.shared.databaseStorageRef.read { tx in - var expiredPlaceholderIds = [String]() - var nextExpirationDate: Date? - InteractionFinder.enumeratePlaceholders(transaction: tx) { placeholder in - guard placeholder.expirationDate.isBeforeNow else { - nextExpirationDate = [nextExpirationDate, placeholder.expirationDate].compacted().min() - return - } - expiredPlaceholderIds.append(placeholder.uniqueId) - } - return (expiredPlaceholderIds, nextExpirationDate) - } - - let batchSize = 25 - var remainingPlaceholderIds = expiredPlaceholderIds[...] - while !remainingPlaceholderIds.isEmpty { - let thisBatchPlaceholderIds = remainingPlaceholderIds.prefix(batchSize) - remainingPlaceholderIds = remainingPlaceholderIds.dropFirst(batchSize) - - await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in - for placeholderId in thisBatchPlaceholderIds { - guard - let placeholder = OWSRecoverableDecryptionPlaceholder.fetchRecoverableDecryptionPlaceholderViaCache( - uniqueId: placeholderId, - transaction: tx, - ) - else { - continue - } - Logger.info("Cleaning up placeholder \(placeholder.timestamp)") - DependenciesBridge.shared.interactionDeleteManager - .delete(placeholder, sideEffects: .default(), tx: tx) - guard let thread = placeholder.thread(tx: tx) else { - return - } - let errorMessage: TSErrorMessage = .failedDecryption( - thread: thread, - timestamp: MessageTimestampGenerator.sharedInstance.generateTimestamp(), - sender: placeholder.sender, - ) - errorMessage.anyInsert(transaction: tx) - SSKEnvironment.shared.notificationPresenterRef.notifyUser(forErrorMessage: errorMessage, thread: thread, transaction: tx) - } - } - } - - if let nextExpirationDate { - await MainActor.run { self.schedulePlaceholderCleanup(noLaterThan: nextExpirationDate) } - } - } - // MARK: - OWSMessageHandler methods private static func descriptionForEnvelopeType(_ envelope: SSKProtoEnvelope) -> String { diff --git a/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.h b/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.h index 4cfe1d0291..9eb23c53d0 100644 --- a/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.h +++ b/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.h @@ -53,8 +53,6 @@ NS_ASSUME_NONNULL_BEGIN untrustedGroupId:(nullable NSData *)untrustedGroupId transaction:(DBWriteTransaction *)writeTx NS_DESIGNATED_INITIALIZER; -@property (assign, nonatomic, readonly) BOOL supportsReplacement; - // --- CODE GENERATION MARKER // This snippet is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run diff --git a/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.m b/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.m index 5a4c924b61..c49db42efc 100644 --- a/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.m +++ b/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.m @@ -123,11 +123,6 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Methods -- (BOOL)supportsReplacement -{ - return [self.expirationDate isAfterNow] && !self.wasRead; -} - - (NSString *)previewTextWithTransaction:(DBReadTransaction *)transaction { NSString *_Nullable senderName = nil; diff --git a/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.swift b/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.swift index dcfb2876f0..e9caff5f1f 100644 --- a/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.swift +++ b/SignalServiceKit/Messages/OWSRecoverableDecryptionPlaceholder.swift @@ -7,7 +7,6 @@ extension OWSRecoverableDecryptionPlaceholder { /// This method performs an upsert replacement of the placeholder with the provided interaction /// Callers should not continue using the placeholder after performing a replacement. - @objc func replaceWithInteraction(_ interaction: TSInteraction, writeTx: DBWriteTransaction) { Logger.info("Replacing placeholder with recovered interaction: \(interaction.timestamp)") guard let inheritedId = sqliteRowId else { return owsFailDebug("Missing rowId") } @@ -19,7 +18,6 @@ extension OWSRecoverableDecryptionPlaceholder { } /// After this date, the placeholder is no longer eligible for replacement with the original content. - @objc var expirationDate: Date { var expirationInterval = RemoteConfig.current.replaceableInteractionExpiration owsAssertDebug(expirationInterval >= 0) @@ -30,4 +28,8 @@ extension OWSRecoverableDecryptionPlaceholder { return self.receivedAtDate.addingTimeInterval(max(0, expirationInterval)) } + + var supportsReplacement: Bool { + expirationDate.isAfterNow && !wasRead + } } diff --git a/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift b/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift index 14df4c08bf..d919291416 100644 --- a/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift +++ b/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift @@ -332,6 +332,7 @@ public class GRDBSchemaMigrator { case wipeCachedSVRBAuthCredentials case addMimeTypeToMessageAttachmentReference case purgeMyStoryDeletedAtTimestamp + case addRecoverablePlaceholderExpirationIndex // NOTE: Every time we add a migration id, consider // incrementing grdbSchemaVersionLatest. @@ -5202,6 +5203,17 @@ public class GRDBSchemaMigrator { return .success(()) } + migrator.registerMigration(.addRecoverablePlaceholderExpirationIndex) { tx in + try tx.database.create( + index: "index_interactions_on_recoverable_placeholder_expiration", + on: "model_TSInteraction", + columns: ["receivedAtTimestamp"], + condition: Column("recordType") == SDSRecordType.recoverableDecryptionPlaceholder.rawValue, + ) + + return .success(()) + } + // MARK: - Schema Migration Insertion Point } diff --git a/SignalServiceKit/Storage/Database/Records/InteractionFinder.swift b/SignalServiceKit/Storage/Database/Records/InteractionFinder.swift index ddab681e0e..c125cf0ad8 100644 --- a/SignalServiceKit/Storage/Database/Records/InteractionFinder.swift +++ b/SignalServiceKit/Storage/Database/Records/InteractionFinder.swift @@ -372,31 +372,32 @@ public class InteractionFinder: NSObject { } } - static func enumeratePlaceholders( - transaction: DBReadTransaction, - block: (OWSRecoverableDecryptionPlaceholder) -> Void, - ) { + static func nextExpiringPlaceholder( + tx: DBReadTransaction, + ) -> OWSRecoverableDecryptionPlaceholder? { let sql = """ SELECT * FROM \(InteractionRecord.databaseTableName) - \(DEBUG_INDEXED_BY("index_interaction_on_recordType_and_callType")) - WHERE \(interactionColumn: .recordType) IS \(SDSRecordType.recoverableDecryptionPlaceholder.rawValue) + INDEXED BY index_interactions_on_recoverable_placeholder_expiration + WHERE \(interactionColumn: .recordType) = \(SDSRecordType.recoverableDecryptionPlaceholder.rawValue) + ORDER BY \(interactionColumn: .receivedAtTimestamp) """ - do { - let cursor = TSInteraction.grdbFetchCursor( - sql: sql, - transaction: transaction, - ) - while let result = try cursor.next() { - if let placeholder = result as? OWSRecoverableDecryptionPlaceholder { - block(placeholder) - } else { - owsFailDebug("Unexpected type: \(type(of: result))") - } - } - } catch { - owsFailDebug("unexpected error \(error)") + + let cursor = TSInteraction.grdbFetchCursor( + sql: sql, + transaction: tx, + ) + + while + // Silently skip malformed TSInteraction rows, lest we become stuck + // on a malformed row forever. + let interaction = try? cursor.next(), + let placeholder = interaction as? OWSRecoverableDecryptionPlaceholder + { + return placeholder } + + return nil } @objc