Convert Decryption Placeholder expiration to an ExpirationJob
This commit is contained in:
parent
d28e29fa21
commit
16c179115e
@ -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 */,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] = []
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user