Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
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,
                )
            }
    }
}