You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKit/Database/Models/ControlMessageProcessRecord...

171 lines
7.7 KiB
Swift

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
/// We can rely on the unique constraints within the `Interaction` table to prevent duplicate `VisibleMessage`
/// values from being processed, but some control messages dont have an associated interaction - this table provides
/// a de-duping mechanism for those messages
///
/// **Note:** Its entirely possible for there to be a false-positive with this record where multiple users sent the same
/// type of control message at the same time - this is very unlikely to occur though since unique to the millisecond level
public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "controlMessageProcessRecord" }
/// For notifications and migrated timestamps default to '15' days (which is the timeout for messages on the
/// server at the time of writing)
public static let defaultExpirationSeconds: TimeInterval = (15 * 24 * 60 * 60)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case threadId
case timestampMs
case variant
case serverExpirationTimestamp
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
@available(*, deprecated, message: "Removed along with legacy db migration") case legacyEntry = 0
case readReceipt = 1
case typingIndicator = 2
case closedGroupControlMessage = 3
case dataExtractionNotification = 4
case expirationTimerUpdate = 5
@available(*, deprecated) case configurationMessage = 6
case unsendRequest = 7
case messageRequestResponse = 8
case call = 9
/// Since we retrieve messages from all snodes in a swarm there is a fun issue where a user can delete a
/// one-to-one conversation (which removes all associated interactions) and then the poller checks a
/// different service node, if a previously processed message hadn't been processed yet for that specific
/// service node it results in the conversation re-appearing
///
/// This `Variant` allows us to create a record which survives thread deletion to prevent a duplicate
/// message from being reprocessed
case visibleMessageDedupe = 10
}
/// The id for the thread the control message is associated to
///
/// **Note:** For user-specific control message (eg. `ConfigurationMessage`) this value will be the
/// users public key
public let threadId: String
/// The type of control message
///
/// **Note:** It would be nice to include this in the unique constraint to reduce the likelihood of false positives
/// but this can result in control messages getting re-handled because the variant is unknown in the migration
public let variant: Variant
/// The timestamp of the control message
public let timestampMs: Int64
/// The timestamp for when this message will expire on the server (will be used for garbage collection)
public let serverExpirationTimestamp: TimeInterval?
// MARK: - Initialization
public init?(
threadId: String,
message: Message,
serverExpirationTimestamp: TimeInterval?
) {
// Allow duplicates for UnsendRequest messages, if a user received an UnsendRequest
// as a push notification the it wouldn't include a serverHash and, as a result,
// wouldn't get deleted from the server - since the logic only runs if we find a
// matching message the safest option is to allow duplicate handling to avoid an
// edge-case where a message doesn't get deleted
if message is UnsendRequest { return nil }
// Allow duplicates for all call messages, the double checking will be done on
// message handling to make sure the messages are for the same ongoing call
if message is CallMessage { return nil }
// Allow '.new' and 'encryptionKeyPair' closed group control message duplicates to avoid
// the following situation:
// The app performed a background poll or received a push notification
// This method was invoked and the received message timestamps table was updated
// Processing wasn't finished
// The user doesn't see the new closed group
if case .new = (message as? ClosedGroupControlMessage)?.kind { return nil }
if case .encryptionKeyPair = (message as? ClosedGroupControlMessage)?.kind { return nil }
// For all other cases we want to prevent duplicate handling of the message (this
// can happen in a number of situations, primarily with sync messages though hence
// why we don't include the 'serverHash' as part of this record
self.threadId = threadId
self.variant = {
switch message {
case is ReadReceipt: return .readReceipt
case is TypingIndicator: return .typingIndicator
case is ClosedGroupControlMessage: return .closedGroupControlMessage
case is DataExtractionNotification: return .dataExtractionNotification
case is ExpirationTimerUpdate: return .expirationTimerUpdate
case is UnsendRequest: return .unsendRequest
case is MessageRequestResponse: return .messageRequestResponse
case is CallMessage: return .call
case is VisibleMessage: return .visibleMessageDedupe
default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type")
}
}()
self.timestampMs = Int64(message.sentTimestamp ?? 0) // Default to `0` if not set
self.serverExpirationTimestamp = serverExpirationTimestamp
}
}
// MARK: - Migration Extensions
internal extension ControlMessageProcessRecord {
init?(
threadId: String,
variant: Interaction.Variant,
timestampMs: Int64
) {
switch variant {
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted,
.infoClosedGroupCreated:
return nil
case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving:
self.variant = .closedGroupControlMessage
case .infoDisappearingMessagesUpdate:
self.variant = .expirationTimerUpdate
case .infoScreenshotNotification, .infoMediaSavedNotification:
self.variant = .dataExtractionNotification
case .infoMessageRequestAccepted:
self.variant = .messageRequestResponse
case .infoCall:
self.variant = .call
}
self.threadId = threadId
self.timestampMs = timestampMs
self.serverExpirationTimestamp = (
(TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) +
ControlMessageProcessRecord.defaultExpirationSeconds
)
}
/// This method should only be called from either the `generateLegacyProcessRecords` method above or
/// within the 'insert' method to maintain the unique constraint
fileprivate init(
threadId: String,
variant: Variant,
timestampMs: Int64,
serverExpirationTimestamp: TimeInterval
) {
self.threadId = threadId
self.variant = variant
self.timestampMs = timestampMs
self.serverExpirationTimestamp = serverExpirationTimestamp
}
}