Path: blob/main/SignalServiceKit/Messages/Edit/EditManagerImpl.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
public class EditManagerImpl: EditManager {
enum Constants {
// RECEIVE
// Edits will only be received for up to 48 hours from the
// original message
static let editWindowMilliseconds: UInt64 = 2 * UInt64.dayInMs
// Receiving more than this number of edits on the same message
// will result in subsequent edits being dropped
static let maxReceiveEdits: UInt = UInt(100)
// SEND
// Edits can only be sent for up to 24 hours from the
// original message
static let editSendWindowMilliseconds: UInt64 = UInt64.dayInMs
// Message can only be edited 10 times
static let maxSendEdits: UInt = UInt(10)
}
public struct Context {
let attachmentContentValidator: AttachmentContentValidator
let attachmentStore: AttachmentStore
let editManagerAttachments: EditManagerAttachments
let editMessageStore: EditMessageStore
let receiptManagerShim: EditManagerImpl.Shims.ReceiptManager
public init(
attachmentContentValidator: AttachmentContentValidator,
attachmentStore: AttachmentStore,
editManagerAttachments: EditManagerAttachments,
editMessageStore: EditMessageStore,
receiptManagerShim: EditManagerImpl.Shims.ReceiptManager,
) {
self.attachmentContentValidator = attachmentContentValidator
self.attachmentStore = attachmentStore
self.editManagerAttachments = editManagerAttachments
self.editMessageStore = editMessageStore
self.receiptManagerShim = receiptManagerShim
}
}
private let context: Context
public init(context: Context) {
self.context = context
}
// MARK: - Incoming Edit Processing
// Process incoming data message
// 1) Check the external edit for valid field values
// 2) Call shared code to create new copies/records
public func processIncomingEditMessage(
_ newDataMessage: SSKProtoDataMessage,
serverTimestamp: UInt64,
serverGuid: String?,
serverDeliveryTimestamp: UInt64,
thread: TSThread,
editTarget: EditMessageTarget,
tx: DBWriteTransaction,
) throws -> TSMessage {
guard let threadRowId = thread.sqliteRowId else {
throw OWSAssertionError("Can't apply edit in uninserted thread")
}
try checkForValidEdit(
thread: thread,
editTarget: editTarget,
editMessage: newDataMessage,
serverTimestamp: serverTimestamp,
tx: tx,
)
var bodyRanges: MessageBodyRanges = .empty
if !newDataMessage.bodyRanges.isEmpty {
bodyRanges = MessageBodyRanges(protos: newDataMessage.bodyRanges)
}
let oversizeText = newDataMessage.attachments
.first(where: {
$0.contentType == MimeType.textXSignalPlain.rawValue
})
.map {
MessageEdits.OversizeTextSource.proto($0)
}
let quotedReplyEdit: MessageEdits.Edit<Void> = {
// If the editMessage quote field is present, preserve the exisiting
// quote. If the field is nil, remove any quote on the current message.
if newDataMessage.quote == nil {
return .change(())
}
return .keep
}()
let linkPreview = newDataMessage.preview.first.map { MessageEdits.LinkPreviewSource.proto($0, newDataMessage) }
let body = newDataMessage.body.map {
context.attachmentContentValidator.truncatedMessageBodyForInlining(
MessageBody(text: $0, ranges: bodyRanges),
tx: tx,
)
}
let edits: MessageEdits = .forIncomingEdit(
timestamp: .change(newDataMessage.timestamp),
// Received now!
receivedAtTimestamp: .change(Date.ows_millisecondTimestamp()),
serverTimestamp: .change(serverTimestamp),
serverDeliveryTimestamp: .change(serverDeliveryTimestamp),
serverGuid: .change(serverGuid),
body: .change(body),
)
let editedMessage = try applyAndInsertEdits(
editTargetWrapper: editTarget.wrapper,
editsToApply: edits,
threadRowId: threadRowId,
newOversizeText: oversizeText,
quotedReplyEdit: quotedReplyEdit,
newLinkPreview: linkPreview,
tx: tx,
)
return editedMessage
}
// MARK: - Edit UI Validation
public func canShowEditMenu(interaction: TSInteraction, thread: TSThread) -> Bool {
return Self.validateCanShowEditMenu(interaction: interaction, thread: thread) == nil
}
private static func validateCanShowEditMenu(
interaction: TSInteraction,
thread: TSThread,
) -> EditSendValidationError? {
guard let message = interaction as? TSOutgoingMessage else { return .messageTypeNotSupported }
if !Self.editMessageTypeSupported(message: message) {
return .messageTypeNotSupported
}
// isVoiceMessage is only available on outgoing messages, so make this check
// here instead of in 'editMessageTypeSupported'. For incoming edits, this
// voice message check is done by inspecting the incoming attachments.
if message.isVoiceMessage {
return .messageTypeNotSupported
}
if thread.isTerminatedGroup {
return .editWindowClosed
}
if !thread.isNoteToSelf {
let (result, isOverflow) = interaction.timestamp.addingReportingOverflow(Constants.editSendWindowMilliseconds)
guard !isOverflow, Date.ows_millisecondTimestamp() <= result else {
return .editWindowClosed
}
}
return nil
}
public func validateCanSendEdit(
targetMessageTimestamp: UInt64,
thread: TSThread,
tx: DBReadTransaction,
) -> EditSendValidationError? {
guard
let editTarget = context.editMessageStore.editTarget(
timestamp: targetMessageTimestamp,
authorAci: nil,
threadUniqueId: thread.uniqueId,
tx: tx,
)
else {
owsFailDebug("Target edit message missing")
return .messageNotFound
}
guard case .outgoingMessage(let targetMessageWrapper) = editTarget else {
return .messageNotFound
}
let targetMessage = targetMessageWrapper.message
if let error = Self.validateCanShowEditMenu(interaction: targetMessage, thread: thread) {
return error
}
let numberOfEdits = context.editMessageStore.numberOfEdits(for: targetMessage, tx: tx)
if !thread.isNoteToSelf, numberOfEdits >= Constants.maxSendEdits {
return .tooManyEdits(Constants.maxSendEdits)
}
return nil
}
// MARK: - Outgoing Edit Send
/// Creates a copy of the passed in `targetMessage`, then constructs
/// an `OutgoingEditMessage` with this new copy. Note that this only creates an
/// in-memory copy and doesn't persist the new message.
public func createOutgoingEditMessage(
targetMessage: TSOutgoingMessage,
thread: TSThread,
edits: MessageEdits,
oversizeText: AttachmentDataSource?,
quotedReplyEdit: MessageEdits.Edit<Void>,
linkPreview: LinkPreviewDataSource?,
tx: DBWriteTransaction,
) throws -> OutgoingEditMessage {
guard let threadRowId = thread.sqliteRowId else {
throw OWSAssertionError("Can't apply edit in uninserted thread")
}
let editTargetWrapper = OutgoingEditMessageWrapper(
message: targetMessage,
thread: thread,
)
let editedMessage = try applyAndInsertEdits(
editTargetWrapper: editTargetWrapper,
editsToApply: edits,
threadRowId: threadRowId,
newOversizeText: oversizeText.map { .dataSource($0) },
quotedReplyEdit: quotedReplyEdit,
newLinkPreview: linkPreview.map { .draft($0) },
tx: tx,
)
let outgoingEditMessage = OutgoingEditMessage(
thread: thread,
targetMessageTimestamp: targetMessage.timestamp,
editMessage: editedMessage,
transaction: tx,
)
return outgoingEditMessage
}
// MARK: - Edit Utilities
/// Apply edits to a target message and insert the edited message as the
/// latest revision, along with records for the now-previous revision.
///
/// - Parameter editTargetWrapper
/// A wrapper around the target message to which edits will be applied.
/// - Parameter editsToApply
/// Describes what edits should be performed on the target message.
/// - Returns
/// The target message with edits applied; i.e., the "latest revision" of
/// the message. The updates to this message will have been persisted.
private func applyAndInsertEdits<EditTarget: EditMessageWrapper>(
editTargetWrapper: EditTarget,
editsToApply: MessageEdits,
threadRowId: Int64,
newOversizeText: MessageEdits.OversizeTextSource?,
quotedReplyEdit: MessageEdits.Edit<Void>,
newLinkPreview: MessageEdits.LinkPreviewSource?,
tx: DBWriteTransaction,
) throws -> EditTarget.MessageType {
/// Create and insert a clone of the existing message, with edits
/// applied.
let latestRevisionMessage = {
let editedMessageBuilder = editTargetWrapper.cloneAsBuilderWithoutAttachments(
applying: editsToApply,
isLatestRevision: true,
attachmentContentValidator: context.attachmentContentValidator,
tx: tx,
)
let editedMessage = EditTarget.build(editedMessageBuilder, tx: tx)
// Swap in the IDs from the original message, so we overwrite it.
editedMessage.replaceRowId(
editTargetWrapper.message.sqliteRowId!,
uniqueId: editTargetWrapper.message.uniqueId,
)
editedMessage.replaceSortId(editTargetWrapper.message.sortId)
editedMessage.anyOverwritingUpdate(transaction: tx)
return editedMessage
}()
let latestRevisionRowId = latestRevisionMessage.sqliteRowId!
/// Create and insert a clone of the original message, preserving all
/// fields, as a record of the now-prior revision of the now-edited
/// message.
///
/// Keep the original message's timestamp, as well as its content.
let priorRevisionMessageBuilder = editTargetWrapper.cloneAsBuilderWithoutAttachments(
applying: .noChanges(),
isLatestRevision: false,
attachmentContentValidator: context.attachmentContentValidator,
tx: tx,
)
let priorRevisionMessage = EditTarget.build(
priorRevisionMessageBuilder,
tx: tx,
)
priorRevisionMessage.anyInsert(transaction: tx)
let priorRevisionRowId = priorRevisionMessage.sqliteRowId!
try context.editManagerAttachments.reconcileAttachments(
uneditedTargetMessage: editTargetWrapper.message,
latestRevision: latestRevisionMessage,
latestRevisionRowId: latestRevisionRowId,
priorRevision: priorRevisionMessage,
priorRevisionRowId: priorRevisionRowId,
threadRowId: threadRowId,
newOversizeText: newOversizeText,
newLinkPreview: newLinkPreview,
quotedReplyEdit: quotedReplyEdit,
tx: tx,
)
// Update the newly inserted message with any data that needs to be
// copied from the original message
editTargetWrapper.updateMessageCopy(
newMessageCopy: priorRevisionMessage,
tx: tx,
)
let editRecord = EditRecord(
latestRevisionId: latestRevisionRowId,
pastRevisionId: priorRevisionRowId,
read: editTargetWrapper.wasRead,
)
do {
try context.editMessageStore.insert(editRecord, tx: tx)
} catch {
owsFailDebug("Unexpected edit record insertion error \(error)")
}
return latestRevisionMessage
}
// MARK: - Incoming Edit Validation
private func checkForValidEdit(
thread: TSThread,
editTarget: EditMessageTarget,
editMessage: SSKProtoDataMessage,
serverTimestamp: UInt64,
tx: DBReadTransaction,
) throws {
let targetMessage = editTarget.wrapper.message
// check edit window (by comparing target message server timestamp
// and incoming edit server timestamp)
// drop silent and warn if outside of valid range
switch editTarget {
case .incomingMessage(let incomingMessage):
guard let originalServerTimestamp = incomingMessage.message.serverTimestamp?.uint64Value else {
throw OWSAssertionError("Edit message target doesn't have a server timestamp")
}
let (result, isOverflow) = originalServerTimestamp.addingReportingOverflow(Constants.editWindowMilliseconds)
guard !isOverflow, serverTimestamp <= result else {
throw OWSAssertionError("Message edit outside of allowed timeframe")
}
case .outgoingMessage:
// Don't validate the edit window for outgoing/sync messages
break
}
let numberOfEdits = context.editMessageStore.numberOfEdits(for: targetMessage, tx: tx)
if numberOfEdits >= Constants.maxReceiveEdits {
throw OWSAssertionError("Message edited too many times")
}
// If this is a group message, validate edit groupID matches the target
if let groupThread = thread as? TSGroupThread {
guard
let masterKey = editMessage.groupV2?.masterKey,
let contextInfo = try? GroupV2ContextInfo.deriveFrom(masterKeyData: masterKey),
contextInfo.groupId.serialize() == groupThread.groupModel.groupId
else {
throw OWSAssertionError("Edit message group does not match target message")
}
}
if !Self.editMessageTypeSupported(message: targetMessage) {
throw OWSAssertionError("Edit of message type not supported")
}
let firstAttachmentRef = context.attachmentStore.fetchAnyReference(
owner: .messageBodyAttachment(messageRowId: targetMessage.sqliteRowId!),
tx: tx,
)
// Voice memos only ever have one attachment; only need to check the first.
if
let firstAttachmentRef,
firstAttachmentRef.renderingFlag == .voiceMessage
{
// This will bail if it finds a voice memo
// Might be able to handle image attachemnts, but fail for now.
throw OWSAssertionError("Voice message edits not supported")
}
// All good!
}
private static func editMessageTypeSupported(
message: TSMessage,
) -> Bool {
// Skip remotely deleted
if message.wasRemotelyDeleted {
return false
}
// Skip view-once
if message.isViewOnceMessage {
return false
}
// Skip restored SMS messages
if message.isSmsMessageRestoredFromBackup {
return false
}
// Skip contact shares
if message.contactShare != nil {
return false
}
if message.messageSticker != nil {
return false
}
// Skip polls.
if message.isPoll {
return false
}
return true
}
// MARK: - Edit Revision Read State
public func markEditRevisionsAsRead(
for edit: TSMessage,
thread: TSThread,
tx: DBWriteTransaction,
) throws {
try context.editMessageStore
.findEditHistory(forMostRecentRevision: edit, tx: tx)
.lazy
.filter { !$0.record.read }
.forEach { item in
guard let message = item.1 as? TSIncomingMessage else { return }
var record: EditRecord = item.0
record.read = true
try self.context.editMessageStore.update(record, tx: tx)
self.context.receiptManagerShim.messageWasRead(
message,
thread: thread,
circumstance: .onThisDevice,
tx: tx,
)
}
}
}