Signal-iOS/SignalServiceKit/Util/ViewOnceMessages.swift
2026-01-21 17:54:15 -06:00

299 lines
10 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
@objc
public class ViewOnceMessages: NSObject {
override private init() {
super.init()
}
// MARK: - Events
private class func nowMs() -> UInt64 {
return NSDate.ows_millisecondTimeStamp()
}
// "Check for auto-completion", e.g. complete messages whether or
// not they have been read after N days. Also complete outgoing
// sent messages.
public static func expireIfNecessary() async throws(CancellationError) {
// Find all view-once messages which are not yet complete.
// Complete messages if necessary.
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
var afterRowId: Int64?
repeat {
if Task.isCancelled {
throw CancellationError()
}
await databaseStorage.awaitableWrite { tx in
let messages: [TSMessage]
(messages, afterRowId) = ViewOnceMessageFinder().fetchSomeIncompleteViewOnceMessages(after: afterRowId, limit: 100, tx: tx)
if !messages.isEmpty {
Logger.info("Checking \(messages.count) view once message(s) for auto-expiration.")
}
for message in messages {
completeIfNecessary(message: message, transaction: tx)
}
}
} while afterRowId != nil
}
@objc
public class func completeIfNecessary(
message: TSMessage,
transaction: DBWriteTransaction,
) {
guard
message.isViewOnceMessage,
!message.isViewOnceComplete
else {
return
}
// If message should auto-complete, complete.
guard !shouldMessageAutoComplete(message) else {
markAsComplete(
message: message,
sendSyncMessages: true,
transaction: transaction,
)
return
}
// If outgoing message and is "sent", complete.
guard !isOutgoingSent(message: message) else {
markAsComplete(
message: message,
sendSyncMessages: true,
transaction: transaction,
)
return
}
// Message should not yet complete.
}
private class func isOutgoingSent(message: TSMessage) -> Bool {
guard message.isViewOnceMessage else {
owsFailDebug("Unexpected message.")
return false
}
// If outgoing message and is "sent", complete.
guard let outgoingMessage = message as? TSOutgoingMessage else {
return false
}
guard outgoingMessage.messageState == .sent else {
return false
}
return true
}
// We auto-complete messages after N days, even if the user hasn't seen them.
private class func shouldMessageAutoComplete(_ message: TSMessage) -> Bool {
let autoCompleteDeadlineMs = min(message.timestamp, message.receivedAtTimestamp) + RemoteConfig.current.messageQueueTimeMs
return nowMs() >= autoCompleteDeadlineMs
}
@objc
public class func markAsComplete(
message: TSMessage,
sendSyncMessages: Bool,
transaction: DBWriteTransaction,
) {
guard message.isViewOnceMessage else {
owsFailDebug("Not a view-once message.")
return
}
guard !message.isViewOnceComplete else {
// Already completed, no need to complete again.
return
}
message.updateWithViewOnceCompleteAndRemoveRenderableContent(with: transaction)
if sendSyncMessages {
sendSyncMessage(forMessage: message, transaction: transaction)
}
}
// MARK: - Sync Messages
private class func sendSyncMessage(forMessage message: TSMessage, transaction: DBWriteTransaction) {
guard let senderAci = senderAci(forMessage: message, tx: transaction) else {
return
}
guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else {
owsFailDebug("Missing thread.")
return
}
let readTimestamp: UInt64 = nowMs()
let syncMessage = OutgoingViewOnceOpenSyncMessage(
localThread: thread,
senderAci: senderAci,
message: message,
readTimestamp: readTimestamp,
tx: transaction,
)
// this is the sync that we viewed; it doesn't have the attachment on it.
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: syncMessage,
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
if let incomingMessage = message as? TSIncomingMessage {
let circumstance: OWSReceiptCircumstance =
thread.hasPendingMessageRequest(transaction: transaction)
? .onThisDeviceWhilePendingMessageRequest
: .onThisDevice
incomingMessage.markAsViewed(
atTimestamp: readTimestamp,
thread: thread,
circumstance: circumstance,
transaction: transaction,
)
}
}
public enum ViewOnceSyncMessageProcessingResult {
case associatedMessageMissing(senderAci: Aci, associatedMessageTimestamp: UInt64)
case invalidSyncMessage
case success
}
public class func processIncomingSyncMessage(
_ message: SSKProtoSyncMessageViewOnceOpen,
envelope: SSKProtoEnvelope,
transaction: DBWriteTransaction,
) -> ViewOnceSyncMessageProcessingResult {
guard
let messageSender = Aci.parseFrom(
serviceIdBinary: message.senderAciBinary,
serviceIdString: message.senderAci,
)
else {
owsFailDebug("Invalid messageSender.")
return .invalidSyncMessage
}
let messageIdTimestamp: UInt64 = message.timestamp
guard messageIdTimestamp > 0, SDS.fitsInInt64(messageIdTimestamp) else {
owsFailDebug("Invalid messageIdTimestamp.")
return .invalidSyncMessage
}
let filter = { (interaction: TSInteraction) -> TSMessage? in
guard let message = interaction as? TSMessage else {
return nil
}
guard let senderAci = senderAci(forMessage: message, tx: transaction) else {
return nil
}
guard senderAci == messageSender else {
return nil
}
guard message.isViewOnceMessage else {
return nil
}
return message
}
let messages: [TSMessage]
do {
messages = try InteractionFinder.fetchInteractions(
timestamp: messageIdTimestamp,
transaction: transaction,
).compactMap(filter)
} catch {
owsFailDebug("Couldn't find interactions: \(error)")
return .invalidSyncMessage
}
guard messages.count > 0 else {
return .associatedMessageMissing(senderAci: messageSender, associatedMessageTimestamp: messageIdTimestamp)
}
if messages.count > 1 {
owsFailDebug("More than one message from the same sender with the same timestamp found.")
}
for message in messages {
// Mark as complete.
markAsComplete(
message: message,
sendSyncMessages: false,
transaction: transaction,
)
}
return .success
}
private static func senderAci(forMessage message: TSMessage, tx: DBReadTransaction) -> Aci? {
if let incomingMessage = message as? TSIncomingMessage {
return incomingMessage.authorAddress.aci
} else if message is TSOutgoingMessage {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
owsFailDebug("Could not process sync message; no local number.")
return nil
}
// We also need to send and receive "per-message expiration read" sync
// messages for outgoing messages, unlike normal read receipts.
return localIdentifiers.aci
} else {
owsFailDebug("Unexpected message type.")
return nil
}
}
}
// MARK: -
private class ViewOnceMessageFinder {
func fetchSomeIncompleteViewOnceMessages(after rowId: Int64?, limit: Int, tx: DBReadTransaction) -> ([TSMessage], mightHaveMoreAfter: Int64?) {
var results: [TSMessage] = []
let cursor: TSInteractionCursor
if let rowId {
cursor = TSInteraction.grdbFetchCursor(
sql: """
SELECT *
FROM \(InteractionRecord.databaseTableName)
\(DEBUG_INDEXED_BY("Interaction_incompleteViewOnce_partial"))
WHERE \(interactionColumn: .isViewOnceMessage) = 1
AND \(interactionColumn: .isViewOnceComplete) = 0
AND \(interactionColumn: .id) > ?
ORDER BY \(interactionColumn: .id)
""",
arguments: [rowId],
transaction: tx,
)
} else {
cursor = TSInteraction.grdbFetchCursor(
sql: """
SELECT *
FROM \(InteractionRecord.databaseTableName)
\(DEBUG_INDEXED_BY("Interaction_incompleteViewOnce_partial"))
WHERE \(interactionColumn: .isViewOnceMessage) = 1
AND \(interactionColumn: .isViewOnceComplete) = 0
ORDER BY \(interactionColumn: .id)
""",
transaction: tx,
)
}
while let next = try! cursor.next() {
guard let message = next as? TSMessage else {
owsFailDebug("expecting message but found: \(next)")
continue
}
results.append(message)
if results.count >= limit {
return (results, mightHaveMoreAfter: message.sqliteRowId!)
}
}
return (results, mightHaveMoreAfter: nil)
}
}