Add message editing feature.

This commit is contained in:
Clark
2023-04-14 16:29:26 -04:00
committed by Cody Henthorne
parent 4f06a0d27c
commit 07f6baf7c1
73 changed files with 2051 additions and 304 deletions

View File

@@ -88,7 +88,7 @@ import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteDeleteUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.isStory
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
@@ -317,7 +317,7 @@ object DataMessageProcessor {
* Inserts an expiration update if the message timer doesn't match the thread timer.
*/
@Throws(StorageFailedException::class)
private fun handlePossibleExpirationUpdate(
fun handlePossibleExpirationUpdate(
envelope: Envelope,
metadata: EnvelopeMetadata,
senderRecipientId: RecipientId,
@@ -482,7 +482,7 @@ object DataMessageProcessor {
return null
}
val targetMessageId = MessageId(targetMessage.id)
val targetMessageId = (targetMessage as? MediaMmsMessageRecord)?.latestRevisionId ?: MessageId(targetMessage.id)
if (isRemove) {
SignalDatabase.reactions.deleteReaction(targetMessageId, senderRecipientId)
@@ -502,7 +502,7 @@ object DataMessageProcessor {
val targetSentTimestamp: Long = message.delete.targetSentTimestamp
val targetMessage: MessageRecord? = SignalDatabase.messages.getMessageFor(targetSentTimestamp, senderRecipientId)
return if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, senderRecipientId, envelope.serverTimestamp)) {
return if (targetMessage != null && MessageConstraintsUtil.isValidRemoteDeleteReceive(targetMessage, senderRecipientId, envelope.serverTimestamp)) {
SignalDatabase.messages.markAsRemoteDelete(targetMessage.id)
if (targetMessage.isStory()) {
SignalDatabase.messages.deleteRemotelyDeletedStory(targetMessage.id)
@@ -944,7 +944,7 @@ object DataMessageProcessor {
GroupCallPeekJob.enqueue(groupRecipientId)
}
private fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
if (threadId > 0 && TextSecurePreferences.isTypingIndicatorsEnabled(context)) {

View File

@@ -0,0 +1,191 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.MessageTable.InsertResult
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.PushProcessEarlyMessagesJob
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
import org.thoughtcrime.securesms.messages.MessageContentProcessorV2.Companion.log
import org.thoughtcrime.securesms.messages.MessageContentProcessorV2.Companion.warn
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.groupId
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.isMediaMessage
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.toPointers
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.hasAudio
import org.thoughtcrime.securesms.util.hasSharedContact
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import java.util.Optional
object EditMessageProcessor {
fun process(
context: Context,
senderRecipient: Recipient,
threadRecipient: Recipient,
envelope: SignalServiceProtos.Envelope,
content: SignalServiceProtos.Content,
metadata: EnvelopeMetadata,
earlyMessageCacheEntry: EarlyMessageCacheEntry?
) {
val editMessage = content.editMessage
log(envelope.timestamp, "[handleEditMessage] Edit message for " + editMessage.targetSentTimestamp)
var targetMessage: MediaMmsMessageRecord? = SignalDatabase.messages.getMessageFor(editMessage.targetSentTimestamp, senderRecipient.id) as MediaMmsMessageRecord
val targetThreadRecipient: Recipient? = if (targetMessage != null) SignalDatabase.threads.getRecipientForThreadId(targetMessage.threadId) else null
if (targetMessage == null || targetThreadRecipient == null) {
warn(envelope.timestamp, "[handleEditMessage] Could not find matching message! timestamp: ${editMessage.targetSentTimestamp} author: ${senderRecipient.id}")
if (earlyMessageCacheEntry != null) {
ApplicationDependencies.getEarlyMessageCache().store(senderRecipient.id, editMessage.targetSentTimestamp, earlyMessageCacheEntry)
PushProcessEarlyMessagesJob.enqueue()
}
return
}
val message = editMessage.dataMessage
val isMediaMessage = message.isMediaMessage
val groupId: GroupId.V2? = message.groupV2.groupId
val originalMessage = targetMessage.originalMessageId?.let { SignalDatabase.messages.getMessageRecord(it.id) } ?: targetMessage
val validTiming = MessageConstraintsUtil.isValidEditMessageReceive(originalMessage, senderRecipient, envelope.serverTimestamp)
val validAuthor = senderRecipient.id == originalMessage.fromRecipient.id
val validGroup = groupId == targetThreadRecipient.groupId.orNull()
val validTarget = !originalMessage.isViewOnce && !originalMessage.hasAudio() && !originalMessage.hasSharedContact()
if (!validTiming || !validAuthor || !validGroup || !validTarget) {
warn(envelope.timestamp, "[handleEditMessage] Invalid message edit! editTime: ${envelope.serverTimestamp}, targetTime: ${originalMessage.serverTimestamp}, editAuthor: ${senderRecipient.id}, targetAuthor: ${originalMessage.fromRecipient.id}, editThread: ${threadRecipient.id}, targetThread: ${targetThreadRecipient.id}, validity: (timing: $validTiming, author: $validAuthor, group: $validGroup, target: $validTarget)")
return
}
if (groupId != null && MessageContentProcessorV2.handleGv2PreProcessing(context, envelope.timestamp, content, metadata, groupId, message.groupV2, senderRecipient)) {
warn(envelope.timestamp, "[handleEditMessage] Group processor indicated we should ignore this.")
return
}
DataMessageProcessor.notifyTypingStoppedFromIncomingMessage(context, senderRecipient, threadRecipient.id, metadata.sourceDeviceId)
targetMessage = targetMessage.withAttachments(context, SignalDatabase.attachments.getAttachmentsForMessage(targetMessage.id))
val insertResult: InsertResult? = if (isMediaMessage || targetMessage.quote != null || targetMessage.slideDeck.slides.isNotEmpty()) {
handleEditMediaMessage(senderRecipient.id, groupId, envelope, metadata, message, targetMessage)
} else {
handleEditTextMessage(senderRecipient.id, groupId, envelope, metadata, message, targetMessage)
}
if (insertResult != null) {
SignalExecutors.BOUNDED.execute {
ApplicationDependencies.getJobManager().add(SendDeliveryReceiptJob(senderRecipient.id, message.timestamp, MessageId(insertResult.messageId)))
}
if (targetMessage.expireStarted > 0) {
ApplicationDependencies.getExpiringMessageManager()
.scheduleDeletion(
insertResult.messageId,
true,
targetMessage.expireStarted,
targetMessage.expiresIn
)
}
ApplicationDependencies.getMessageNotifier().updateNotification(context, forConversation(insertResult.threadId))
}
}
private fun handleEditMediaMessage(
senderRecipientId: RecipientId,
groupId: GroupId.V2?,
envelope: SignalServiceProtos.Envelope,
metadata: EnvelopeMetadata,
message: DataMessage,
targetMessage: MediaMmsMessageRecord
): InsertResult? {
val messageRanges: BodyRangeList? = message.bodyRangesList.filter { it.hasStyle() }.toList().toBodyRangeList()
val targetQuote = targetMessage.quote
val quote: QuoteModel? = if (targetQuote != null && message.hasQuote()) {
QuoteModel(
targetQuote.id,
targetQuote.author,
targetQuote.displayText.toString(),
targetQuote.isOriginalMissing,
emptyList(),
null,
targetQuote.quoteType,
null
)
} else {
null
}
val attachments = message.attachmentsList.toPointers()
attachments.filter {
MediaUtil.SlideType.LONG_TEXT == MediaUtil.getSlideTypeFromContentType(it.contentType)
}
val mediaMessage = IncomingMediaMessage(
from = senderRecipientId,
sentTimeMillis = message.timestamp,
serverTimeMillis = envelope.serverTimestamp,
receivedTimeMillis = targetMessage.receiptTimestamp,
expiresIn = targetMessage.expiresIn,
isViewOnce = message.isViewOnce,
isUnidentified = metadata.sealedSender,
body = message.body,
groupId = groupId,
attachments = attachments,
quote = quote,
sharedContacts = emptyList(),
linkPreviews = DataMessageProcessor.getLinkPreviews(message.previewList, message.body ?: "", false),
mentions = DataMessageProcessor.getMentions(message.bodyRangesList),
serverGuid = envelope.serverGuid,
messageRanges = messageRanges,
isPushMessage = true
)
return SignalDatabase.messages.insertEditMessageInbox(-1, mediaMessage, targetMessage).orNull()
}
private fun handleEditTextMessage(
senderRecipientId: RecipientId,
groupId: GroupId.V2?,
envelope: SignalServiceProtos.Envelope,
metadata: EnvelopeMetadata,
message: DataMessage,
targetMessage: MediaMmsMessageRecord
): InsertResult? {
var textMessage = IncomingTextMessage(
senderRecipientId,
metadata.sourceDeviceId,
envelope.timestamp,
envelope.timestamp,
targetMessage.receiptTimestamp,
message.body,
Optional.ofNullable(groupId),
targetMessage.expiresIn,
metadata.sealedSender,
envelope.serverGuid
)
textMessage = IncomingEncryptedMessage(textMessage, message.body)
return SignalDatabase.messages.insertEditMessageInbox(textMessage, targetMessage).orNull()
}
}

View File

@@ -37,6 +37,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
@@ -94,29 +95,15 @@ public final class GroupSendUtil {
@NonNull MessageId messageId,
@NonNull SignalServiceDataMessage message,
boolean urgent,
boolean isForStory)
boolean isForStory,
@Nullable SignalServiceEditMessage editMessage)
throws IOException, UntrustedIdentityException
{
Preconditions.checkArgument(groupId == null || distributionListId == null, "Cannot supply both a groupId and a distributionListId!");
DistributionId distributionId = groupId != null ? getDistributionId(groupId) : getDistributionId(distributionListId);
return sendMessage(context, groupId, distributionId, messageId, allTargets, isRecipientUpdate, isForStory, DataSendOperation.resendable(message, contentHint, messageId, urgent, isForStory), null);
}
@WorkerThread
public static List<SendMessageResult> sendResendableStoryRelatedMessage(@NonNull Context context,
@Nullable GroupId.V2 groupId,
@NonNull DistributionListId distributionListId,
@NonNull List<Recipient> allTargets,
boolean isRecipientUpdate,
ContentHint contentHint,
@NonNull MessageId messageId,
@NonNull SignalServiceDataMessage message,
boolean urgent)
throws IOException, UntrustedIdentityException
{
return sendMessage(context, groupId, getDistributionId(distributionListId), messageId, allTargets, isRecipientUpdate, true, DataSendOperation.resendable(message, contentHint, messageId, urgent, true), null);
return sendMessage(context, groupId, distributionId, messageId, allTargets, isRecipientUpdate, isForStory, DataSendOperation.resendable(message, contentHint, messageId, urgent, isForStory, editMessage), null);
}
/**
@@ -480,22 +467,24 @@ public final class GroupSendUtil {
private final boolean resendable;
private final boolean urgent;
private final boolean isForStory;
private final SignalServiceEditMessage editMessage;
public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, @NonNull MessageId relatedMessageId, boolean urgent, boolean isForStory) {
return new DataSendOperation(message, contentHint, true, relatedMessageId, urgent, isForStory);
public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, @NonNull MessageId relatedMessageId, boolean urgent, boolean isForStory, @Nullable SignalServiceEditMessage editMessage) {
return new DataSendOperation(editMessage != null ? editMessage.getDataMessage() : message, contentHint, true, relatedMessageId, urgent, isForStory, editMessage);
}
public static DataSendOperation unresendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean urgent) {
return new DataSendOperation(message, contentHint, false, null, urgent, false);
return new DataSendOperation(message, contentHint, false, null, urgent, false, null);
}
private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, @Nullable MessageId relatedMessageId, boolean urgent, boolean isForStory) {
private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, @Nullable MessageId relatedMessageId, boolean urgent, boolean isForStory, @Nullable SignalServiceEditMessage editMessage) {
this.message = message;
this.contentHint = contentHint;
this.resendable = resendable;
this.relatedMessageId = relatedMessageId;
this.urgent = urgent;
this.isForStory = isForStory;
this.editMessage = editMessage;
if (resendable && relatedMessageId == null) {
throw new IllegalArgumentException("If a message is resendable, it must have a related message ID!");
@@ -512,7 +501,7 @@ public final class GroupSendUtil {
throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException, InvalidRegistrationIdException
{
SenderKeyGroupEvents listener = relatedMessageId != null ? new SenderKeyMetricEventListener(relatedMessageId.getId()) : SenderKeyGroupEvents.EMPTY;
return messageSender.sendGroupDataMessage(distributionId, targets, access, isRecipientUpdate, contentHint, message, listener, urgent, isForStory, partialListener);
return messageSender.sendGroupDataMessage(distributionId, targets, access, isRecipientUpdate, contentHint, message, listener, urgent, isForStory, editMessage, partialListener);
}
@Override
@@ -527,8 +516,14 @@ public final class GroupSendUtil {
{
// PniSignatures are only needed for 1:1 messages, but some message jobs use the GroupSendUtil methods to send 1:1
if (targets.size() == 1 && relatedMessageId == null) {
Recipient targetRecipient = targetRecipients.get(0);
SendMessageResult result = messageSender.sendDataMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, targetRecipient.needsPniSignature());
Recipient targetRecipient = targetRecipients.get(0);
SendMessageResult result;
if (editMessage != null) {
result = messageSender.sendEditMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, editMessage.getTargetSentTimestamp());
} else {
result = messageSender.sendDataMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, targetRecipient.needsPniSignature());
}
if (targetRecipient.needsPniSignature()) {
SignalDatabase.pendingPniSignatureMessages().insertIfNecessary(targetRecipients.get(0).getId(), getSentTimestamp(), result);

View File

@@ -134,7 +134,7 @@ import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.LinkUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -1064,7 +1064,7 @@ public class MessageContentProcessor {
MessageRecord targetMessage = SignalDatabase.messages().getMessageFor(delete.getTargetSentTimestamp(), senderRecipient.getId());
if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, senderRecipient, content.getServerReceivedTimestamp())) {
if (targetMessage != null && MessageConstraintsUtil.isValidRemoteDeleteReceive(targetMessage, senderRecipient.getId(), content.getServerReceivedTimestamp())) {
MessageTable db = targetMessage.isMms() ? SignalDatabase.messages() : SignalDatabase.messages();
db.markAsRemoteDelete(targetMessage.getId());
if (MessageRecordUtil.isStory(targetMessage)) {
@@ -2220,7 +2220,8 @@ public class MessageContentProcessor {
null,
true,
bodyRanges,
-1);
-1,
0);
if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) {
handleSynchronizeSentExpirationUpdate(message);
@@ -2342,7 +2343,8 @@ public class MessageContentProcessor {
null,
true,
bodyRanges,
-1);
-1,
0);
MessageTable messageTable = SignalDatabase.messages();
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
@@ -2441,7 +2443,8 @@ public class MessageContentProcessor {
giftBadge.orElse(null),
true,
bodyRanges,
-1);
-1,
0);
if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) {
handleSynchronizeSentExpirationUpdate(message);

View File

@@ -111,6 +111,8 @@ open class MessageContentProcessorV2(private val context: Context) {
getGroupRecipient(content.storyMessage.group, sender)
} else if (content.dataMessage.hasGroupContext) {
getGroupRecipient(content.dataMessage.groupV2, sender)
} else if (content.editMessage.dataMessage.hasGroupContext) {
getGroupRecipient(content.editMessage.dataMessage.groupV2, sender)
} else {
sender
}
@@ -379,6 +381,21 @@ open class MessageContentProcessorV2(private val context: Context) {
content.hasDecryptionErrorMessage() -> {
handleRetryReceipt(envelope, metadata, content.decryptionErrorMessage!!.toDecryptionErrorMessage(metadata), senderRecipient)
}
content.hasEditMessage() -> {
if (FeatureFlags.editMessageReceiving()) {
EditMessageProcessor.process(
context,
senderRecipient,
threadRecipient,
envelope,
content,
metadata,
if (processingEarlyContent) null else EarlyMessageCacheEntry(envelope, content, metadata, serverDeliveredTimestamp)
)
} else {
warn(envelope.timestamp, "Got message edit, but processing is disabled")
}
}
content.hasSenderKeyDistributionMessage() || content.hasPniSignatureMessage() -> {
// Already handled, here in order to prevent unrecognized message log
}