mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
509 lines
23 KiB
Swift
509 lines
23 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import SessionUtilitiesKit
|
|
import SessionNetworkingKit
|
|
|
|
// MARK: - Log.Category
|
|
|
|
private extension Log.Category {
|
|
static let cat: Log.Category = .create("MessageDeduplication", defaultLevel: .info)
|
|
}
|
|
|
|
// MARK: - MessageDeduplication
|
|
|
|
public struct MessageDeduplication: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
|
public static var databaseTableName: String { "messageDeduplication" }
|
|
|
|
public typealias Columns = CodingKeys
|
|
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
|
case threadId
|
|
case uniqueIdentifier
|
|
case expirationTimestampSeconds
|
|
case shouldDeleteWhenDeletingThread
|
|
}
|
|
|
|
public let threadId: String
|
|
public let uniqueIdentifier: String
|
|
public let expirationTimestampSeconds: Int64?
|
|
public let shouldDeleteWhenDeletingThread: Bool
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
public extension MessageDeduplication {
|
|
static func insert(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant?,
|
|
uniqueIdentifier: String?,
|
|
legacyIdentifier: String? = nil,
|
|
message: Message?,
|
|
serverExpirationTimestamp: TimeInterval?,
|
|
ignoreDedupeFiles: Bool,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
/// If we don't have a `uniqueIdentifier` then we can't dedupe the message
|
|
guard let uniqueIdentifier: String = uniqueIdentifier else { return }
|
|
|
|
/// If we aren't ignoring dedupe files then check to ensure they don't already exist (ie. a message was received as a push
|
|
/// notification but doesn't yet exist in the database)
|
|
if !ignoreDedupeFiles {
|
|
/// Ensure this isn't a duplicate message received as a PN first
|
|
try ensureMessageIsNotADuplicate(
|
|
threadId: threadId,
|
|
uniqueIdentifier: uniqueIdentifier,
|
|
legacyIdentifier: legacyIdentifier,
|
|
using: dependencies
|
|
)
|
|
|
|
/// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the same call
|
|
try ensureCallMessageIsNotADuplicate(
|
|
threadId: threadId,
|
|
callMessage: message as? CallMessage,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
/// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds`
|
|
/// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server
|
|
let finalExpiryTimestampSeconds: Int64? = serverExpirationTimestamp
|
|
.map { Int64($0) + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) }
|
|
|
|
/// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the
|
|
/// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll)
|
|
///
|
|
/// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order
|
|
/// to prevent retrieving old messages
|
|
///
|
|
/// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that
|
|
/// can cause issues if we process them again so we hold on to those just in case
|
|
let shouldDeleteWhenDeletingThread: Bool = {
|
|
switch (threadVariant, message.map { Message.Variant(from: $0) }) {
|
|
case (.contact, _): return false
|
|
case (.community, _), (.legacyGroup, _): return true
|
|
case (.group, .groupUpdateInvite), (.group, .groupUpdatePromote),
|
|
(.group, .groupUpdateMemberLeft), (.group, .groupUpdateInviteResponse):
|
|
return false
|
|
case (.group, _): return true
|
|
case (.none, .none), (.none, _): return false
|
|
}
|
|
}()
|
|
|
|
/// Insert the `MessageDeduplication` record
|
|
_ = try MessageDeduplication(
|
|
threadId: threadId,
|
|
uniqueIdentifier: uniqueIdentifier,
|
|
expirationTimestampSeconds: finalExpiryTimestampSeconds,
|
|
shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread
|
|
).insert(db)
|
|
|
|
/// Create the replicated file in the 'AppGroup' so that the PN extension is able to dedupe messages
|
|
try createDedupeFile(
|
|
threadId: threadId,
|
|
uniqueIdentifier: uniqueIdentifier,
|
|
legacyIdentifier: legacyIdentifier,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Insert & create special call-specific dedupe records
|
|
try insertCallDedupeRecordsIfNeeded(
|
|
db,
|
|
threadId: threadId,
|
|
callMessage: message as? CallMessage,
|
|
expirationTimestampSeconds: finalExpiryTimestampSeconds,
|
|
shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Create a legacy dedupe record
|
|
try createLegacyDeduplicationRecord(
|
|
db,
|
|
threadId: threadId,
|
|
legacyIdentifier: legacyIdentifier,
|
|
legacyVariant: getLegacyVariant(for: message.map { Message.Variant(from: $0) }),
|
|
timestampMs: message?.sentTimestampMs.map { Int64($0) },
|
|
serverExpirationTimestamp: serverExpirationTimestamp,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
static func deleteIfNeeded(
|
|
_ db: ObservingDatabase,
|
|
threadIds: [String],
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
/// First update the rows to be considered expired (so they are garbage collected in case the file deletion fails for some reason)
|
|
try MessageDeduplication
|
|
.filter(threadIds.contains(MessageDeduplication.Columns.threadId))
|
|
.filter(MessageDeduplication.Columns.shouldDeleteWhenDeletingThread == true)
|
|
.updateAll(
|
|
db,
|
|
MessageDeduplication.Columns.expirationTimestampSeconds.set(to: 0)
|
|
)
|
|
|
|
/// Then fetch the records and delete them from the database
|
|
let records: [MessageDeduplication] = try MessageDeduplication
|
|
.filter(threadIds.contains(MessageDeduplication.Columns.threadId))
|
|
.filter(MessageDeduplication.Columns.shouldDeleteWhenDeletingThread == true)
|
|
.fetchAll(db)
|
|
|
|
/// Kick off a task to do file I/O (don't want to block the database waiting for file operations to complete)
|
|
Task { [extensionHelper = dependencies[singleton: .extensionHelper], storage = dependencies[singleton: .storage]] in
|
|
/// Upsert the "Last Cleared" record (we do this first just in case there are a lot of message to process and it takes a long time)
|
|
threadIds.forEach { threadId in
|
|
do { try extensionHelper.upsertLastClearedRecord(threadId: threadId) }
|
|
catch { Log.warn(.cat, "Failed to update the last cleared record for \(threadId).") }
|
|
}
|
|
|
|
/// Remove any dedupe record files that should be removed
|
|
var deletedRecords: [MessageDeduplication] = []
|
|
|
|
records.forEach { record in
|
|
do {
|
|
try extensionHelper.removeDedupeRecord(
|
|
threadId: record.threadId,
|
|
uniqueIdentifier: record.uniqueIdentifier
|
|
)
|
|
|
|
deletedRecords.append(record)
|
|
}
|
|
catch { Log.warn(.cat, "Failed to delete dedupe file record (will rely on garbage collection).") }
|
|
}
|
|
|
|
/// Only delete the `MessageDeduplication` records that had their dedupe files successfully removed (if doing
|
|
/// so fails then garbage collection will clean up the file and the record)
|
|
storage.writeAsync { db in
|
|
deletedRecords.forEach { record in
|
|
_ = try? MessageDeduplication
|
|
.filter(MessageDeduplication.Columns.threadId == record.threadId)
|
|
.filter(MessageDeduplication.Columns.uniqueIdentifier == record.uniqueIdentifier)
|
|
.deleteAll(db)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static func createDedupeFile(
|
|
threadId: String,
|
|
uniqueIdentifier: String,
|
|
legacyIdentifier: String? = nil,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
try dependencies[singleton: .extensionHelper].createDedupeRecord(
|
|
threadId: threadId,
|
|
uniqueIdentifier: uniqueIdentifier
|
|
)
|
|
|
|
/// Also create a dedupe file for the legacy identifier if provided
|
|
guard let legacyIdentifier: String = legacyIdentifier else { return }
|
|
|
|
try dependencies[singleton: .extensionHelper].createDedupeRecord(
|
|
threadId: threadId,
|
|
uniqueIdentifier: legacyIdentifier
|
|
)
|
|
}
|
|
|
|
static func ensureMessageIsNotADuplicate(
|
|
_ processedMessage: ProcessedMessage,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
typealias Variant = _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant
|
|
try ensureMessageIsNotADuplicate(
|
|
threadId: processedMessage.threadId,
|
|
uniqueIdentifier: processedMessage.uniqueIdentifier,
|
|
legacyIdentifier: getLegacyIdentifier(for: processedMessage),
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
static func ensureMessageIsNotADuplicate(
|
|
threadId: String,
|
|
uniqueIdentifier: String,
|
|
legacyIdentifier: String? = nil,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
if dependencies[singleton: .extensionHelper].dedupeRecordExists(
|
|
threadId: threadId,
|
|
uniqueIdentifier: uniqueIdentifier
|
|
) {
|
|
throw MessageReceiverError.duplicateMessage
|
|
}
|
|
|
|
/// Also check for a dedupe file using the legacy identifier
|
|
guard let legacyIdentifier: String = legacyIdentifier else { return }
|
|
|
|
if dependencies[singleton: .extensionHelper].dedupeRecordExists(
|
|
threadId: threadId,
|
|
uniqueIdentifier: legacyIdentifier
|
|
) {
|
|
throw MessageReceiverError.duplicateMessage
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CallMessage Convenience
|
|
|
|
public extension MessageDeduplication {
|
|
static func insertCallDedupeRecordsIfNeeded(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
callMessage: CallMessage?,
|
|
expirationTimestampSeconds: Int64?,
|
|
shouldDeleteWhenDeletingThread: Bool,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
guard let callMessage: CallMessage = callMessage else { return }
|
|
|
|
switch (callMessage.kind, callMessage.state) {
|
|
/// If the call was ended, was missed or had a permission issue then reject all subsequent messages associated with the call
|
|
case (.endCall, _), (_, .missed), (_, .permissionDenied), (_, .permissionDeniedMicrophone):
|
|
_ = try MessageDeduplication(
|
|
threadId: threadId,
|
|
uniqueIdentifier: callMessage.uuid,
|
|
expirationTimestampSeconds: expirationTimestampSeconds,
|
|
shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread
|
|
).insert(db)
|
|
|
|
/// We only want to handle a single `preOffer` so add a custom record for that
|
|
case (.preOffer, _):
|
|
_ = try MessageDeduplication(
|
|
threadId: threadId,
|
|
uniqueIdentifier: callMessage.preOfferDedupeIdentifier,
|
|
expirationTimestampSeconds: expirationTimestampSeconds,
|
|
shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread
|
|
).insert(db)
|
|
|
|
/// For any other combinations we don't want to deduplicate messages (as they are needed to keep the call going)
|
|
default: break
|
|
}
|
|
|
|
/// Create the replicated file in the 'AppGroup' so that the PN extension is able to dedupe call messages
|
|
try createCallDedupeFilesIfNeeded(
|
|
threadId: threadId,
|
|
callMessage: callMessage,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
static func createCallDedupeFilesIfNeeded(
|
|
threadId: String,
|
|
callMessage: CallMessage?,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
guard let callMessage: CallMessage = callMessage else { return }
|
|
|
|
switch (callMessage.kind, callMessage.state) {
|
|
/// If the call was ended, was missed or had a permission issue then reject all subsequent messages associated with the call
|
|
case (.endCall, _), (_, .missed), (_, .permissionDenied), (_, .permissionDeniedMicrophone):
|
|
try dependencies[singleton: .extensionHelper].createDedupeRecord(
|
|
threadId: threadId,
|
|
uniqueIdentifier: callMessage.uuid
|
|
)
|
|
|
|
/// We only want to handle a single `preOffer` so add a custom record for that
|
|
case (.preOffer, _):
|
|
try dependencies[singleton: .extensionHelper].createDedupeRecord(
|
|
threadId: threadId,
|
|
uniqueIdentifier: callMessage.preOfferDedupeIdentifier
|
|
)
|
|
|
|
/// For any other combinations we don't want to deduplicate messages (as they are needed to keep the call going)
|
|
default: break
|
|
}
|
|
}
|
|
|
|
static func ensureCallMessageIsNotADuplicate(
|
|
threadId: String,
|
|
callMessage: CallMessage?,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
guard let callMessage: CallMessage = callMessage else { return }
|
|
|
|
do {
|
|
/// We only want to handle the `preOffer` message once
|
|
if callMessage.kind == .preOffer {
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
threadId: threadId,
|
|
uniqueIdentifier: callMessage.preOfferDedupeIdentifier,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
/// If a call has officially "ended" then we don't want to handle _any_ further messages related to it
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
threadId: threadId,
|
|
uniqueIdentifier: callMessage.uuid,
|
|
using: dependencies
|
|
)
|
|
}
|
|
catch { throw MessageReceiverError.duplicatedCall }
|
|
}
|
|
}
|
|
|
|
// MARK: - ProcessedMessage Convenience
|
|
|
|
public extension MessageDeduplication {
|
|
static func insert(
|
|
_ db: ObservingDatabase,
|
|
processedMessage: ProcessedMessage,
|
|
ignoreDedupeFiles: Bool,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
/// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything
|
|
/// special then it could result in unexpected behaviours where config messages don't get merged correctly
|
|
switch processedMessage {
|
|
case .config, .invalid: return
|
|
case .standard(_, let threadVariant, _, let messageInfo, _):
|
|
try insert(
|
|
db,
|
|
threadId: processedMessage.threadId,
|
|
threadVariant: threadVariant,
|
|
uniqueIdentifier: processedMessage.uniqueIdentifier,
|
|
legacyIdentifier: getLegacyIdentifier(for: processedMessage),
|
|
message: messageInfo.message,
|
|
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
|
ignoreDedupeFiles: ignoreDedupeFiles,
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
|
|
static func createDedupeFile(
|
|
_ processedMessage: ProcessedMessage,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
/// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything
|
|
/// special then it could result in unexpected behaviours where config messages don't get merged correctly
|
|
switch processedMessage {
|
|
case .config, .invalid: return
|
|
case .standard:
|
|
try createDedupeFile(
|
|
threadId: processedMessage.threadId,
|
|
uniqueIdentifier: processedMessage.uniqueIdentifier,
|
|
legacyIdentifier: getLegacyIdentifier(for: processedMessage),
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy Dedupe Records
|
|
|
|
public extension MessageDeduplication {
|
|
@available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)")
|
|
static let doesCreateLegacyRecords: Bool = true
|
|
}
|
|
|
|
private extension MessageDeduplication {
|
|
@available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)")
|
|
private static func createLegacyDeduplicationRecord(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
legacyIdentifier: String?,
|
|
legacyVariant: _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant?,
|
|
timestampMs: Int64?,
|
|
serverExpirationTimestamp: TimeInterval?,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
typealias Variant = _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant
|
|
guard
|
|
let legacyIdentifier: String = legacyIdentifier,
|
|
let legacyVariant: Variant = legacyVariant,
|
|
let timestampMs: Int64 = timestampMs
|
|
else { return }
|
|
|
|
let expirationTimestampSeconds: Int64? = {
|
|
/// If we have a server expiration for the hash then we should use that value as the priority
|
|
if let serverExpirationTimestamp: TimeInterval = serverExpirationTimestamp {
|
|
return Int64(serverExpirationTimestamp)
|
|
}
|
|
|
|
/// If we got here then it means we have no way to know when the message should expire but messages stored on
|
|
/// a snode as well as outgoing blinded message reuqests stored on a SOGS both have a similar default expiration
|
|
/// so create one manually by using `SnodeReceivedMessage.defaultExpirationMs`
|
|
///
|
|
/// For a `contact` conversation at the time of writing this migration there _shouldn't_ be any type of message
|
|
/// which never expires or has it's TTL extended (outside of config messages)
|
|
///
|
|
/// If we have a `timestampMs` then base our custom expiration on that
|
|
return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000)
|
|
}()
|
|
|
|
/// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds`
|
|
/// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server
|
|
let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds
|
|
.map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) }
|
|
|
|
/// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the
|
|
/// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll)
|
|
///
|
|
/// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order
|
|
/// to prevent retrieving old messages
|
|
///
|
|
/// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that
|
|
/// can cause issues if we process them again so we hold on to those just in case
|
|
let shouldDeleteWhenDeletingThread: Bool = {
|
|
switch legacyVariant {
|
|
case .groupUpdateInvite, .groupUpdatePromote, .groupUpdateMemberLeft,
|
|
.groupUpdateInviteResponse:
|
|
return false
|
|
default: return true
|
|
}
|
|
}()
|
|
|
|
/// Add the record
|
|
_ = try MessageDeduplication(
|
|
threadId: threadId,
|
|
uniqueIdentifier: legacyIdentifier,
|
|
expirationTimestampSeconds: finalExpiryTimestampSeconds,
|
|
shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread
|
|
).insert(db)
|
|
}
|
|
|
|
@available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)")
|
|
static func getLegacyVariant(for variant: Message.Variant?) -> _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant? {
|
|
guard let variant: Message.Variant = variant else { return nil }
|
|
|
|
switch variant {
|
|
case .visibleMessage: return .visibleMessageDedupe
|
|
case .readReceipt: return .readReceipt
|
|
case .typingIndicator: return .typingIndicator
|
|
case .unsendRequest: return .unsendRequest
|
|
case .dataExtractionNotification: return .dataExtractionNotification
|
|
case .expirationTimerUpdate: return .expirationTimerUpdate
|
|
case .messageRequestResponse: return .messageRequestResponse
|
|
case .callMessage: return .call
|
|
case .groupUpdateInvite, .groupUpdateMemberChange, .groupUpdatePromote:
|
|
return .groupUpdateMemberChange
|
|
case .groupUpdateInfoChange: return .groupUpdateInfoChange
|
|
case .groupUpdateMemberLeft: return .groupUpdateMemberLeft
|
|
case .groupUpdateMemberLeftNotification: return .groupUpdateMemberLeftNotification
|
|
case .groupUpdateInviteResponse: return .groupUpdateInviteResponse
|
|
case .groupUpdateDeleteMemberContent: return .groupUpdateDeleteMemberContent
|
|
|
|
case .libSessionMessage: return nil
|
|
}
|
|
}
|
|
|
|
@available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)")
|
|
static func getLegacyIdentifier(for processedMessage: ProcessedMessage) -> String? {
|
|
switch processedMessage {
|
|
case .config, .invalid: return nil
|
|
case .standard(_, _, _, let messageInfo, _):
|
|
guard
|
|
let timestampMs: UInt64 = messageInfo.message.sentTimestampMs,
|
|
let variant: _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant = getLegacyVariant(for: Message.Variant(from: messageInfo.message))
|
|
else { return nil }
|
|
|
|
return "LegacyRecord-\(variant.rawValue)-\(timestampMs)" // stringlint:ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
public extension CallMessage {
|
|
var preOfferDedupeIdentifier: String { "\(uuid)-preOffer" }
|
|
}
|
|
|