Convert Decryption Placeholder expiration to an ExpirationJob

This commit is contained in:
Sasha Weiss 2026-06-03 13:58:46 -07:00 committed by GitHub
parent d28e29fa21
commit 16c179115e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 113 additions and 120 deletions

View File

@ -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 = "<group>"; };
D94441302D559567005B2A54 /* UUIDv7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDv7.swift; sourceTree = "<group>"; };
D94441322D559C6B005B2A54 /* UUIDv7Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDv7Test.swift; sourceTree = "<group>"; };
D944D2FC2FBCE9C00026FD21 /* OWSDecryptionPlaceholderExpirationJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDecryptionPlaceholderExpirationJob.swift; sourceTree = "<group>"; };
D945319D2CE53CC8004DAB30 /* SubscriptionRedemptionNecessityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRedemptionNecessityChecker.swift; sourceTree = "<group>"; };
D94852262F6A223500B130B2 /* GroupCallVideoContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallVideoContextMenuConfiguration.swift; sourceTree = "<group>"; };
D9495A6C2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSOutgoingMessageRecipientState.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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()

View File

@ -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,

View File

@ -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()
},
)

View File

@ -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

View File

@ -21,10 +21,11 @@ open class ExpirationJob<ExpiringElement> {
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] = []

View File

@ -0,0 +1,55 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public final class OWSDecryptionPlaceholderExpirationJob: ExpirationJob<OWSRecoverableDecryptionPlaceholder> {
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)
}
}

View File

@ -9,10 +9,6 @@ public class OWSMessageDecrypter {
private let senderIdsResetDuringCurrentBatch = AtomicValue<Set<String>>(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 {

View File

@ -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

View File

@ -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;

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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