apply message corrections

This commit is contained in:
Daniel Gultsch 2023-02-11 15:25:06 +01:00
parent be3a8dc5e1
commit a69b4b14a5
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
8 changed files with 199 additions and 32 deletions

View file

@ -70,6 +70,7 @@ public final class Namespace {
public static final String MUC = "http://jabber.org/protocol/muc"; public static final String MUC = "http://jabber.org/protocol/muc";
public static final String MUC_USER = MUC + "#user"; public static final String MUC_USER = MUC + "#user";
public static final String NICK = "http://jabber.org/protocol/nick"; public static final String NICK = "http://jabber.org/protocol/nick";
public static final String OCCUPANT_ID = "urn:xmpp:occupant-id:0";
public static final String OMEMO_DTLS_SRTP_VERIFICATION = public static final String OMEMO_DTLS_SRTP_VERIFICATION =
"http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification"; "http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification";
public static final String OOB = "jabber:x:oob"; public static final String OOB = "jabber:x:oob";

View file

@ -57,6 +57,10 @@ public abstract class MessageDao {
} }
} }
// this method returns a MessageIdentifier (message + version) used to create ORIGINAL messages
// it might return something that was previously a stub (message that only has reactions or
// corrections but no original content). but in the process of invoking this method the stub
// will be upgraded to an original message (missing information filled in)
@Transaction @Transaction
public MessageIdentifier getOrCreateMessage( public MessageIdentifier getOrCreateMessage(
ChatIdentifier chatIdentifier, final Transformation transformation) { ChatIdentifier chatIdentifier, final Transformation transformation) {
@ -96,6 +100,96 @@ public abstract class MessageDao {
messageVersionId); messageVersionId);
} }
// this gets either a message or a stub.
// stubs are recognized by latestVersion=NULL
// when found by stanzaId the stanzaId must either by verified or belonging to a stub
// when found by messageId the from must either match (for corrections) or not be set (null) and
// we only look up stubs
// TODO the from matcher should be in the outer condition
@Query(
"SELECT id,stanzaId,messageId,fromBare,latestVersion as version FROM message WHERE"
+ " chatId=:chatId AND (fromBare=:fromBare OR fromBare=NULL) AND ((stanzaId !="
+ " NULL AND stanzaId=:stanzaId AND (stanzaIdVerified=1 OR latestVersion=NULL)) OR"
+ " (stanzaId = NULL AND messageId=:messageId AND latestVersion = NULL))")
abstract MessageIdentifier get(long chatId, Jid fromBare, String stanzaId, String messageId);
public MessageIdentifier getOrCreateVersion(
ChatIdentifier chat,
Transformation transformation,
final String messageId,
final Modification modification) {
Preconditions.checkArgument(
messageId != null, "A modification must reference a message id");
final MessageIdentifier messageIdentifier;
if (transformation.occupantId == null) {
messageIdentifier = getByMessageId(chat.id, transformation.fromBare(), messageId);
} else {
messageIdentifier =
getByOccupantIdAndMessageId(
chat.id,
transformation.fromBare(),
transformation.occupantId,
messageId);
}
if (messageIdentifier == null) {
LOGGER.info(
"Create stub for {} because we could not find anything with id {} from {}",
modification,
messageId,
transformation.fromBare());
final var messageEntity = MessageEntity.stub(chat.id, messageId, transformation);
final long messageEntityId = insert(messageEntity);
final long messageVersionId =
insert(MessageVersionEntity.of(messageEntityId, modification, transformation));
// we do not point latestVersion to this newly created versions. We've only created a
// stub and are waiting for the original message to arrive
return new MessageIdentifier(
messageEntityId, null, null, transformation.fromBare(), messageVersionId);
}
if (hasVersionWithMessageId(messageIdentifier.id, transformation.messageId)) {
throw new IllegalStateException(
String.format(
"A modification with messageId %s has already been applied",
messageId));
}
final long messageVersionId =
insert(MessageVersionEntity.of(messageIdentifier.id, modification, transformation));
if (messageIdentifier.version != null) {
// if the existing message was not a stub we retarget the version
final long latestVersion = getLatestVersion(messageIdentifier.id);
setLatestMessageId(messageIdentifier.id, latestVersion);
}
return new MessageIdentifier(
messageIdentifier.id,
messageIdentifier.stanzaId,
messageIdentifier.messageId,
messageIdentifier.fromBare,
messageVersionId);
}
@Query(
"SELECT id,stanzaId,messageId,fromBare,latestVersion as version FROM message WHERE"
+ " chatId=:chatId AND (fromBare=:fromBare OR fromBare IS NULL) AND"
+ " (occupantId=:occupantId OR occupantId IS NULL) AND messageId=:messageId")
abstract MessageIdentifier getByOccupantIdAndMessageId(
long chatId, Jid fromBare, String occupantId, String messageId);
@Query(
"SELECT id,stanzaId,messageId,fromBare,latestVersion as version FROM message WHERE"
+ " chatId=:chatId AND (fromBare=:fromBare OR fromBare IS NULL) AND"
+ " messageId=:messageId")
abstract MessageIdentifier getByMessageId(long chatId, Jid fromBare, String messageId);
@Query(
"SELECT id FROM message_version WHERE messageEntityId=:messageEntityId ORDER BY (CASE"
+ " modification WHEN 'ORIGINAL' THEN 0 ELSE 1 END),receivedAt DESC LIMIT 1")
abstract Long getLatestVersion(long messageEntityId);
@Query(
"SELECT EXISTS (SELECT id FROM message_version WHERE messageEntityId=:messageEntityId"
+ " AND messageId=:messageId)")
abstract boolean hasVersionWithMessageId(long messageEntityId, String messageId);
@Insert @Insert
protected abstract long insert(MessageEntity messageEntity); protected abstract long insert(MessageEntity messageEntity);
@ -114,19 +208,6 @@ public abstract class MessageDao {
return null; return null;
} }
// this gets either a message or a stub.
// stubs are recognized by latestVersion=NULL
// when found by stanzaId the stanzaId must either by verified or belonging to a stub
// when found by messageId the from must either match (for corrections) or not be set (null) and
// we only look up stubs
// TODO the from matcher should be in the outer condition
@Query(
"SELECT id,stanzaId,messageId,fromBare,latestVersion as version FROM message WHERE"
+ " chatId=:chatId AND (fromBare=:fromBare OR fromBare=NULL) AND ((stanzaId !="
+ " NULL AND stanzaId=:stanzaId AND (stanzaIdVerified=1 OR latestVersion=NULL)) OR"
+ " (stanzaId = NULL AND messageId=:messageId AND latestVersion = NULL))")
abstract MessageIdentifier get(long chatId, Jid fromBare, String stanzaId, String messageId);
public void insertMessageContent(Long latestVersion, List<MessageContent> contents) { public void insertMessageContent(Long latestVersion, List<MessageContent> contents) {
Preconditions.checkNotNull( Preconditions.checkNotNull(
latestVersion, "Contents can only be inserted for a specific version"); latestVersion, "Contents can only be inserted for a specific version");

View file

@ -65,9 +65,20 @@ public class MessageEntity {
entity.toResource = transformation.toResource(); entity.toResource = transformation.toResource();
entity.fromBare = transformation.fromBare(); entity.fromBare = transformation.fromBare();
entity.fromResource = transformation.fromResource(); entity.fromResource = transformation.fromResource();
entity.occupantId = transformation.occupantId;
entity.messageId = transformation.messageId; entity.messageId = transformation.messageId;
entity.stanzaId = transformation.stanzaId; entity.stanzaId = transformation.stanzaId;
entity.stanzaIdVerified = Objects.nonNull(transformation.stanzaId); entity.stanzaIdVerified = Objects.nonNull(transformation.stanzaId);
return entity; return entity;
} }
public static MessageEntity stub(
final long chatId, String messageId, Transformation transformation) {
final var entity = new MessageEntity();
entity.chatId = chatId;
entity.fromBare = transformation.fromBare();
entity.messageId = messageId;
entity.stanzaIdVerified = false;
return entity;
}
} }

View file

@ -9,6 +9,8 @@ import im.conversations.android.xmpp.model.DeliveryReceipt;
import im.conversations.android.xmpp.model.DeliveryReceiptRequest; import im.conversations.android.xmpp.model.DeliveryReceiptRequest;
import im.conversations.android.xmpp.model.Extension; import im.conversations.android.xmpp.model.Extension;
import im.conversations.android.xmpp.model.axolotl.Encrypted; import im.conversations.android.xmpp.model.axolotl.Encrypted;
import im.conversations.android.xmpp.model.correction.Replace;
import im.conversations.android.xmpp.model.error.Error;
import im.conversations.android.xmpp.model.jabber.Body; import im.conversations.android.xmpp.model.jabber.Body;
import im.conversations.android.xmpp.model.jabber.Thread; import im.conversations.android.xmpp.model.jabber.Thread;
import im.conversations.android.xmpp.model.markers.Displayed; import im.conversations.android.xmpp.model.markers.Displayed;
@ -31,7 +33,8 @@ public class Transformation {
OutOfBandData.class, OutOfBandData.class,
DeliveryReceipt.class, DeliveryReceipt.class,
MultiUserChat.class, MultiUserChat.class,
Displayed.class); Displayed.class,
Replace.class);
public final Instant receivedAt; public final Instant receivedAt;
public final Jid to; public final Jid to;
@ -41,6 +44,8 @@ public class Transformation {
public final String messageId; public final String messageId;
public final String stanzaId; public final String stanzaId;
public final String occupantId;
private final List<Extension> extensions; private final List<Extension> extensions;
public final Collection<DeliveryReceiptRequest> deliveryReceiptRequests; public final Collection<DeliveryReceiptRequest> deliveryReceiptRequests;
@ -53,6 +58,7 @@ public class Transformation {
final Message.Type type, final Message.Type type,
final String messageId, final String messageId,
final String stanzaId, final String stanzaId,
final String occupantId,
final List<Extension> extensions, final List<Extension> extensions,
final Collection<DeliveryReceiptRequest> deliveryReceiptRequests) { final Collection<DeliveryReceiptRequest> deliveryReceiptRequests) {
this.receivedAt = receivedAt; this.receivedAt = receivedAt;
@ -62,6 +68,7 @@ public class Transformation {
this.type = type; this.type = type;
this.messageId = messageId; this.messageId = messageId;
this.stanzaId = stanzaId; this.stanzaId = stanzaId;
this.occupantId = occupantId;
this.extensions = extensions; this.extensions = extensions;
this.deliveryReceiptRequests = deliveryReceiptRequests; this.deliveryReceiptRequests = deliveryReceiptRequests;
} }
@ -97,11 +104,21 @@ public class Transformation {
} }
public <E extends Extension> E getExtension(final Class<E> clazz) { public <E extends Extension> E getExtension(final Class<E> clazz) {
checkArgument(clazz);
final var extension = Iterables.find(this.extensions, clazz::isInstance, null); final var extension = Iterables.find(this.extensions, clazz::isInstance, null);
return extension == null ? null : clazz.cast(extension); return extension == null ? null : clazz.cast(extension);
} }
private void checkArgument(final Class<? extends Extension> clazz) {
if (EXTENSION_FOR_TRANSFORMATION.contains(clazz) || clazz == Error.class) {
return;
}
throw new IllegalArgumentException(
String.format("%s has not been registered for transformation", clazz.getName()));
}
public <E extends Extension> Collection<E> getExtensions(final Class<E> clazz) { public <E extends Extension> Collection<E> getExtensions(final Class<E> clazz) {
checkArgument(clazz);
return Collections2.transform( return Collections2.transform(
Collections2.filter(this.extensions, clazz::isInstance), clazz::cast); Collections2.filter(this.extensions, clazz::isInstance), clazz::cast);
} }
@ -110,7 +127,8 @@ public class Transformation {
@NonNull final Message message, @NonNull final Message message,
@NonNull final Instant receivedAt, @NonNull final Instant receivedAt,
@NonNull final Jid remote, @NonNull final Jid remote,
final String stanzaId) { final String stanzaId,
final String occupantId) {
final var to = message.getTo(); final var to = message.getTo();
final var from = message.getFrom(); final var from = message.getFrom();
final var type = message.getType(); final var type = message.getType();
@ -134,6 +152,7 @@ public class Transformation {
type, type,
messageId, messageId,
stanzaId, stanzaId,
occupantId,
extensionListBuilder.build(), extensionListBuilder.build(),
requests); requests);
} }

View file

@ -1,8 +1,11 @@
package im.conversations.android.transformer; package im.conversations.android.transformer;
import android.content.Context; import android.content.Context;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.DiscoManager;
import im.conversations.android.xmpp.model.occupant.OccupantId;
import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.stanza.Message;
import java.time.Instant; import java.time.Instant;
@ -27,7 +30,18 @@ public class TransformationFactory extends XmppConnection.Delegate {
} else { } else {
remote = from; remote = from;
} }
// TODO parse occupant on group chats final String occupantId;
return Transformation.of(message, receivedAt, remote, stanzaId); if (message.getType() == Message.Type.GROUPCHAT && message.hasExtension(OccupantId.class)) {
if (from != null
&& getManager(DiscoManager.class)
.hasFeature(from.asBareJid(), Namespace.OCCUPANT_ID)) {
occupantId = message.getExtension(OccupantId.class).getId();
} else {
occupantId = null;
}
} else {
occupantId = null;
}
return Transformation.of(message, receivedAt, remote, stanzaId, occupantId);
} }
} }

View file

@ -8,7 +8,9 @@ import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.ChatIdentifier; import im.conversations.android.database.model.ChatIdentifier;
import im.conversations.android.database.model.MessageContent; import im.conversations.android.database.model.MessageContent;
import im.conversations.android.database.model.MessageIdentifier;
import im.conversations.android.database.model.MessageState; import im.conversations.android.database.model.MessageState;
import im.conversations.android.database.model.Modification;
import im.conversations.android.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.DeliveryReceipt;
import im.conversations.android.xmpp.model.axolotl.Encrypted; import im.conversations.android.xmpp.model.axolotl.Encrypted;
import im.conversations.android.xmpp.model.correction.Replace; import im.conversations.android.xmpp.model.correction.Replace;
@ -17,6 +19,7 @@ import im.conversations.android.xmpp.model.markers.Displayed;
import im.conversations.android.xmpp.model.muc.user.MultiUserChat; import im.conversations.android.xmpp.model.muc.user.MultiUserChat;
import im.conversations.android.xmpp.model.oob.OutOfBandData; import im.conversations.android.xmpp.model.oob.OutOfBandData;
import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.stanza.Message;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -66,31 +69,43 @@ public class Transformer {
chat, transformation.messageId, MessageState.error(transformation)); chat, transformation.messageId, MessageState.error(transformation));
return false; return false;
} }
final Replace lastMessageCorrection = transformation.getExtension(Replace.class); final Replace messageCorrection = transformation.getExtension(Replace.class);
final List<MessageContent> contents = parseContent(transformation); final List<MessageContent> contents = parseContent(transformation);
// TODO this also needs to be true for retractions once we support those (anything that final boolean identifiableSender =
// creates a new message version Arrays.asList(Message.Type.NORMAL, Message.Type.CHAT).contains(messageType)
// TODO a type=groupchat message correction is only valid with an occupant id || Objects.nonNull(transformation.occupantId);
final boolean versionModification = Objects.nonNull(lastMessageCorrection); final boolean isMessageCorrection =
Objects.nonNull(messageCorrection)
&& messageCorrection.getId() != null
&& identifiableSender;
if (contents.isEmpty()) { if (contents.isEmpty()) {
LOGGER.info("Received message from {} w/o contents", transformation.from); LOGGER.info("Received message from {} w/o contents", transformation.from);
transformMessageState(chat, transformation); transformMessageState(chat, transformation);
// TODO apply reactions // TODO apply reactions
} else { } else {
if (versionModification) { final MessageIdentifier messageIdentifier;
// TODO use getOrStub try {
// TODO check if versionModification has already been applied if (isMessageCorrection) {
messageIdentifier =
database.messageDao()
.getOrCreateVersion(
chat,
transformation,
messageCorrection.getId(),
Modification.EDIT);
// TODO for replaced message create a new version; re-target latestVersion } else {
messageIdentifier =
} else { database.messageDao().getOrCreateMessage(chat, transformation);
final var messageIdentifier = }
database.messageDao().getOrCreateMessage(chat, transformation); } catch (final IllegalStateException e) {
database.messageDao().insertMessageContent(messageIdentifier.version, contents); LOGGER.warn("Could not get message identifier", e);
return true; return false;
} }
database.messageDao().insertMessageContent(messageIdentifier.version, contents);
return true;
} }
return true; return true;
} }

View file

@ -1,10 +1,18 @@
package im.conversations.android.xmpp.model.correction; package im.conversations.android.xmpp.model.correction;
import com.google.common.base.Strings;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension; import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.LAST_MESSAGE_CORRECTION)
public class Replace extends Extension { public class Replace extends Extension {
public Replace() { public Replace() {
super(Replace.class); super(Replace.class);
} }
public String getId() {
return Strings.emptyToNull(this.getAttribute("id"));
}
} }

View file

@ -0,0 +1,18 @@
package im.conversations.android.xmpp.model.occupant;
import com.google.common.base.Strings;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
@XmlElement(namespace = Namespace.OCCUPANT_ID)
public class OccupantId extends Extension {
public OccupantId() {
super(OccupantId.class);
}
public String getId() {
return Strings.emptyToNull(this.getAttribute("id"));
}
}