Signal-iOS/SignalServiceKit/src/Messages/MessageSendLog.swift
Michelle Linington 980efad30a Fix broken test
2021-09-01 12:39:50 -07:00

372 lines
14 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import SignalClient
import GRDB
@objc
public class MessageSendLog: NSObject {
private static let payloadLifetime = RemoteConfig.messageSendLogEntryLifetime
private static var expiredPayloadTimestamp: UInt64 {
Date(timeIntervalSinceNow: -payloadLifetime).ows_millisecondsSince1970
}
@objc
class Payload: NSObject, Codable, FetchableRecord, MutablePersistableRecord {
static let databaseTableName = "MessageSendLog_Payload"
static let recipient = hasMany(Recipient.self)
var payloadId: Int64?
@objc
let plaintextContent: Data
let contentHint: SealedSenderContentHint
@objc
let sentTimestamp: UInt64
@objc
let uniqueThreadId: String
// Indicates whether or not this payload is in the process of being sent.
// Used to prevent deletion of the MSL entry if a recipient acks delivery
// before we've finished sending to another recipient.
var sendComplete: Bool
init(
plaintextContent: Data,
contentHint: SealedSenderContentHint,
sentTimestamp: UInt64,
uniqueThreadId: String,
sendComplete: Bool
) {
self.plaintextContent = plaintextContent
self.contentHint = contentHint
self.sentTimestamp = sentTimestamp
self.uniqueThreadId = uniqueThreadId
self.sendComplete = sendComplete
}
func didInsert(with rowID: Int64, for column: String?) {
guard column == "payloadId" else { return owsFailDebug("Expected payloadId") }
payloadId = rowID
}
}
struct Recipient: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "MessageSendLog_Recipient"
static let payload = belongsTo(Payload.self)
private let payloadId: Int64
private let recipientUUID: String
private let recipientDeviceId: Int64
init(payloadId: Int64, recipientUUID: String, recipientDeviceId: Int64) {
self.payloadId = payloadId
self.recipientUUID = recipientUUID
self.recipientDeviceId = recipientDeviceId
}
}
struct Message: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "MessageSendLog_Message"
static let payloadId = belongsTo(Payload.self)
private let payloadId: Int64
private let uniqueId: String
init(payloadId: Int64, uniqueId: String) {
self.payloadId = payloadId
self.uniqueId = uniqueId
}
}
@objc
public static func recordPayload(
_ plaintext: Data,
forMessageBeingSent message: TSOutgoingMessage,
transaction writeTx: SDSAnyWriteTransaction
) -> NSNumber? {
guard !RemoteConfig.messageResendKillSwitch else {
Logger.info("Resend kill switch activated. Ignoring MSL payload save.")
return nil
}
guard message.shouldRecordSendLog else { return nil }
let payloads: [Payload]
do {
payloads = try Payload
.filter(Column("sentTimestamp") == message.timestamp)
.filter(Column("uniqueThreadId") == message.uniqueThreadId)
.fetchAll(writeTx.unwrapGrdbRead.database)
} catch {
owsFailDebug("")
return nil
}
if let existingPayload = payloads.first {
// We found an existing payload. This message was probably a partial failure the first time
// Double check the plaintext matches. If it does, we can use the existing payloadId
// If not, we can't record MSL entries for subsequent sends because the timestamp
// and threadId will alias each other.
if payloads.count == 1, existingPayload.plaintextContent == plaintext, let payloadId = existingPayload.payloadId {
Logger.info("Reusing existing payloadId from a previous send: \(payloadId)")
// If we're working to record a payload, this message is no longer complete
// We set "sendComplete" false to make sure if a delivery receipt comes in
// before we finish sending to the remaining recipients that we don't clear
// out our payload.
do {
existingPayload.sendComplete = false
try existingPayload.update(writeTx.unwrapGrdbWrite.database)
} catch {
owsFailDebug("Failed to mark existing payload incomplete.")
}
return NSNumber(value: payloadId)
} else if message.isSyncMessage {
// If a sync message aliases with another message, it's not great but it's not a major issue.
// The MSL is critical for correct behavior of sender key messages. For non sender key messages, it's
// a nice-to-have in case some unforseen decryption failure happens.
//
// Since sync messages aren't ever sent through sender key, just note that we aliased and continue.
Logger.warn("Sync message: \(message) aliased with existing payload entry. Skipping MSL record")
return nil
} else {
// This failure is only serious if it's a non-sync message. Fail debug to get our attention.
owsAssertDebug(payloads.count == 1)
owsAssertDebug(existingPayload.plaintextContent == plaintext)
owsAssertDebug(existingPayload.payloadId != nil)
owsFailDebug("MSL payload table inconsistency")
return nil
}
} else {
// No existing payload found. Create a new one and insert it
var payload = Payload(
plaintextContent: plaintext,
contentHint: message.contentHint,
sentTimestamp: message.timestamp,
uniqueThreadId: message.uniqueThreadId,
sendComplete: false)
do {
try payload.insert(writeTx.unwrapGrdbWrite.database)
Logger.info("Inserted MSL payload with id: \(String(describing: payload.payloadId))")
// If the payload was successfully recorded, we should also record
// any interactions related to this payload. This should not fail.
guard let payloadId = payload.payloadId else {
throw OWSAssertionError("Expected payloadId to be set")
}
try message.relatedUniqueIds.forEach { uniqueId in
try Message(payloadId: payloadId, uniqueId: uniqueId)
.insert(writeTx.unwrapGrdbWrite.database)
}
return NSNumber(value: payloadId)
} catch {
owsFailDebug("Unexpected MSL payload insertion error \(error)")
return nil
}
}
}
@objc
static func fetchPayload(
address: SignalServiceAddress,
deviceId: Int64,
timestamp: UInt64,
transaction readTx: SDSAnyReadTransaction
) -> Payload? {
return fetchPayload(address: address, deviceId: deviceId, timestamp: timestamp, allowExpired: false, transaction: readTx)
}
private static func fetchPayload(
address: SignalServiceAddress,
deviceId: Int64,
timestamp: UInt64,
allowExpired: Bool,
transaction readTx: SDSAnyReadTransaction
) -> Payload? {
guard !RemoteConfig.messageResendKillSwitch else {
Logger.info("Resend kill switch activated. Ignoring MSL lookup.")
return nil
}
guard timestamp > expiredPayloadTimestamp || allowExpired else {
Logger.info("Ignoring payload lookup for timestamp before expiration")
return nil
}
do {
let recipientAlias = TableAlias()
let request = Payload
.joining(required: Payload.recipient.aliased(recipientAlias))
.filter(Column("sentTimestamp") == timestamp)
.filter(recipientAlias[Column("recipientUUID")] == address.uuid?.uuidString)
.filter(recipientAlias[Column("recipientDeviceId")] == deviceId)
let payloads = try Payload.fetchAll(readTx.unwrapGrdbRead.database, request)
if payloads.count == 1, let result = payloads.first {
return result
} else {
return nil
}
} catch {
owsFailDebug("\(error)")
return nil
}
}
@objc
public static func sendComplete(message: TSOutgoingMessage, transaction writeTx: SDSAnyWriteTransaction) {
guard !RemoteConfig.messageResendKillSwitch else {
Logger.info("Resend kill switch activated. Ignoring MSL payload save.")
return
}
guard message.shouldRecordSendLog else { return }
let payloads: [Payload]
do {
payloads = try Payload
.filter(Column("sentTimestamp") == message.timestamp)
.filter(Column("uniqueThreadId") == message.uniqueThreadId)
.fetchAll(writeTx.unwrapGrdbRead.database)
guard let payload = payloads.first else { return }
guard payloads.count <= 1 else {
throw OWSAssertionError("Aliased entries: \(payloads.count)")
}
// We found the payload that needs to be marked complete!
// - If there are any outstanding deliveries, the payload needs to be kept while
// we wait for delivery receipts from our recipients.
// - If every recipient has acked already (this would be an unlikely race) we should
// delete the payload now. Our trigger to prune on updates to the recipient table
// won't catch this.
let hasPendingDeliveries = try Recipient
.filter(Column("payloadId") == payload.payloadId)
.fetchCount(writeTx.unwrapGrdbWrite.database) > 0
if hasPendingDeliveries {
payload.sendComplete = true
try payload.update(writeTx.unwrapGrdbWrite.database)
} else {
try payload.delete(writeTx.unwrapGrdbWrite.database)
}
} catch {
owsFailDebug("Failed to mark send complete for \(message.timestamp): \(error)")
}
}
public static func recordPendingDelivery(
payloadId: Int64,
recipientUuid: UUID,
recipientDeviceId: Int64,
transaction writeTx: SDSAnyWriteTransaction
) {
guard !RemoteConfig.messageResendKillSwitch else {
Logger.info("Resend kill switch activated. Ignoring MSL recipient save.")
return
}
do {
try Recipient(
payloadId: payloadId,
recipientUUID: recipientUuid.uuidString,
recipientDeviceId: recipientDeviceId
).insert(writeTx.unwrapGrdbWrite.database)
} catch {
owsFailDebug("Failed to record pending delivery \(error)")
}
}
@objc
public static func recordSuccessfulDelivery(
timestamp: UInt64,
recipientUuid: UUID,
recipientDeviceId: Int64,
transaction writeTx: SDSAnyWriteTransaction
) {
guard !RemoteConfig.messageResendKillSwitch else {
Logger.info("Resend kill switch activated. Ignoring MSL recipient save.")
return
}
do {
let payloadAlias = TableAlias()
let targets: [Recipient] = try Recipient
.joining(required: Recipient.payload.aliased(payloadAlias))
.filter(payloadAlias[Column("sentTimestamp")] == timestamp)
.filter(Column("recipientUuid") == recipientUuid.uuidString)
.filter(Column("recipientDeviceId") == recipientDeviceId)
.fetchAll(writeTx.unwrapGrdbWrite.database)
try targets.forEach { try $0.delete(writeTx.unwrapGrdbWrite.database) }
} catch {
owsFailDebug("Failed to record successful delivery \(error)")
}
}
@objc
public static func deleteAllPayloadsForInteraction(
_ interaction: TSInteraction,
transaction writeTx: SDSAnyWriteTransaction
) {
Logger.info("Deleting all MSL payload entries related to \(interaction.uniqueId)")
do {
try Message
.filter(Column("uniqueId") == interaction.uniqueId)
.deleteAll(writeTx.unwrapGrdbWrite.database)
} catch {
owsFailDebug("Failed to delete payloads for interaction(\(interaction.uniqueId)): \(error)")
}
}
public static func schedulePeriodicCleanup() {
guard CurrentAppContext().isMainApp, !CurrentAppContext().isRunningTests else { return }
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
performPeriodicCleanup()
}
}
private static func performPeriodicCleanup() {
DispatchQueue.sharedBackground.async {
databaseStorage.write { writeTx in
forceCleanupStaleEntries(transaction: writeTx)
}
}
DispatchQueue.sharedBackground.asyncAfter(deadline: .now() + kDayInterval) {
performPeriodicCleanup()
}
}
private static func forceCleanupStaleEntries(transaction: SDSAnyWriteTransaction) {
do {
try Payload
.filter(Column("sentTimestamp") < expiredPayloadTimestamp)
.deleteAll(transaction.unwrapGrdbWrite.database)
Logger.info("Trimmed stale entries of MSL")
} catch {
owsFailDebug("Failed to trim stale MSL entries: \(error)")
}
}
#if TESTABLE_BUILD
static func test_forceCleanupStaleEntries(transaction: SDSAnyWriteTransaction) {
forceCleanupStaleEntries(transaction: transaction)
}
#endif
#if TESTABLE_BUILD
static func test_fetchPayload(
address: SignalServiceAddress,
deviceId: Int64,
timestamp: UInt64,
allowExpired: Bool,
transaction readTx: SDSAnyReadTransaction
) -> Payload? {
return fetchPayload(address: address, deviceId: deviceId, timestamp: timestamp, allowExpired: allowExpired, transaction: readTx)
}
#endif
}
extension SealedSenderContentHint: Codable {}