diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index f048f8491..0cf1f4364 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -70,6 +70,7 @@ public final class Namespace { public static final String MUC = "http://jabber.org/protocol/muc"; public static final String MUC_USER = MUC + "#user"; 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 = "http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification"; public static final String OOB = "jabber:x:oob"; diff --git a/src/main/java/im/conversations/android/database/dao/MessageDao.java b/src/main/java/im/conversations/android/database/dao/MessageDao.java index 834197fc8..a1b0abe09 100644 --- a/src/main/java/im/conversations/android/database/dao/MessageDao.java +++ b/src/main/java/im/conversations/android/database/dao/MessageDao.java @@ -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 public MessageIdentifier getOrCreateMessage( ChatIdentifier chatIdentifier, final Transformation transformation) { @@ -96,6 +100,96 @@ public abstract class MessageDao { 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 protected abstract long insert(MessageEntity messageEntity); @@ -114,19 +208,6 @@ public abstract class MessageDao { 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 contents) { Preconditions.checkNotNull( latestVersion, "Contents can only be inserted for a specific version"); diff --git a/src/main/java/im/conversations/android/database/entity/MessageEntity.java b/src/main/java/im/conversations/android/database/entity/MessageEntity.java index be76cb7f6..0eef6aa5b 100644 --- a/src/main/java/im/conversations/android/database/entity/MessageEntity.java +++ b/src/main/java/im/conversations/android/database/entity/MessageEntity.java @@ -65,9 +65,20 @@ public class MessageEntity { entity.toResource = transformation.toResource(); entity.fromBare = transformation.fromBare(); entity.fromResource = transformation.fromResource(); + entity.occupantId = transformation.occupantId; entity.messageId = transformation.messageId; entity.stanzaId = transformation.stanzaId; entity.stanzaIdVerified = Objects.nonNull(transformation.stanzaId); 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; + } } diff --git a/src/main/java/im/conversations/android/transformer/Transformation.java b/src/main/java/im/conversations/android/transformer/Transformation.java index 88220e14e..b037aecc1 100644 --- a/src/main/java/im/conversations/android/transformer/Transformation.java +++ b/src/main/java/im/conversations/android/transformer/Transformation.java @@ -9,6 +9,8 @@ import im.conversations.android.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.DeliveryReceiptRequest; import im.conversations.android.xmpp.model.Extension; 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.Thread; import im.conversations.android.xmpp.model.markers.Displayed; @@ -31,7 +33,8 @@ public class Transformation { OutOfBandData.class, DeliveryReceipt.class, MultiUserChat.class, - Displayed.class); + Displayed.class, + Replace.class); public final Instant receivedAt; public final Jid to; @@ -41,6 +44,8 @@ public class Transformation { public final String messageId; public final String stanzaId; + public final String occupantId; + private final List extensions; public final Collection deliveryReceiptRequests; @@ -53,6 +58,7 @@ public class Transformation { final Message.Type type, final String messageId, final String stanzaId, + final String occupantId, final List extensions, final Collection deliveryReceiptRequests) { this.receivedAt = receivedAt; @@ -62,6 +68,7 @@ public class Transformation { this.type = type; this.messageId = messageId; this.stanzaId = stanzaId; + this.occupantId = occupantId; this.extensions = extensions; this.deliveryReceiptRequests = deliveryReceiptRequests; } @@ -97,11 +104,21 @@ public class Transformation { } public E getExtension(final Class clazz) { + checkArgument(clazz); final var extension = Iterables.find(this.extensions, clazz::isInstance, null); return extension == null ? null : clazz.cast(extension); } + private void checkArgument(final Class 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 Collection getExtensions(final Class clazz) { + checkArgument(clazz); return Collections2.transform( Collections2.filter(this.extensions, clazz::isInstance), clazz::cast); } @@ -110,7 +127,8 @@ public class Transformation { @NonNull final Message message, @NonNull final Instant receivedAt, @NonNull final Jid remote, - final String stanzaId) { + final String stanzaId, + final String occupantId) { final var to = message.getTo(); final var from = message.getFrom(); final var type = message.getType(); @@ -134,6 +152,7 @@ public class Transformation { type, messageId, stanzaId, + occupantId, extensionListBuilder.build(), requests); } diff --git a/src/main/java/im/conversations/android/transformer/TransformationFactory.java b/src/main/java/im/conversations/android/transformer/TransformationFactory.java index aa51dfcf5..9368741aa 100644 --- a/src/main/java/im/conversations/android/transformer/TransformationFactory.java +++ b/src/main/java/im/conversations/android/transformer/TransformationFactory.java @@ -1,8 +1,11 @@ package im.conversations.android.transformer; import android.content.Context; +import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; 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 java.time.Instant; @@ -27,7 +30,18 @@ public class TransformationFactory extends XmppConnection.Delegate { } else { remote = from; } - // TODO parse occupant on group chats - return Transformation.of(message, receivedAt, remote, stanzaId); + final String occupantId; + 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); } } diff --git a/src/main/java/im/conversations/android/transformer/Transformer.java b/src/main/java/im/conversations/android/transformer/Transformer.java index b8c64a768..8d194ad3f 100644 --- a/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/src/main/java/im/conversations/android/transformer/Transformer.java @@ -8,7 +8,9 @@ import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.ChatIdentifier; 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.Modification; import im.conversations.android.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.axolotl.Encrypted; 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.oob.OutOfBandData; import im.conversations.android.xmpp.model.stanza.Message; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Objects; @@ -66,31 +69,43 @@ public class Transformer { chat, transformation.messageId, MessageState.error(transformation)); return false; } - final Replace lastMessageCorrection = transformation.getExtension(Replace.class); + final Replace messageCorrection = transformation.getExtension(Replace.class); final List contents = parseContent(transformation); - // TODO this also needs to be true for retractions once we support those (anything that - // creates a new message version - // TODO a type=groupchat message correction is only valid with an occupant id - final boolean versionModification = Objects.nonNull(lastMessageCorrection); + final boolean identifiableSender = + Arrays.asList(Message.Type.NORMAL, Message.Type.CHAT).contains(messageType) + || Objects.nonNull(transformation.occupantId); + final boolean isMessageCorrection = + Objects.nonNull(messageCorrection) + && messageCorrection.getId() != null + && identifiableSender; if (contents.isEmpty()) { LOGGER.info("Received message from {} w/o contents", transformation.from); transformMessageState(chat, transformation); // TODO apply reactions } else { - if (versionModification) { - // TODO use getOrStub - // TODO check if versionModification has already been applied + final MessageIdentifier messageIdentifier; + try { + if (isMessageCorrection) { + messageIdentifier = + database.messageDao() + .getOrCreateVersion( + chat, + transformation, + messageCorrection.getId(), + Modification.EDIT); - // TODO for replaced message create a new version; re-target latestVersion - - } else { - final var messageIdentifier = - database.messageDao().getOrCreateMessage(chat, transformation); - database.messageDao().insertMessageContent(messageIdentifier.version, contents); - return true; + } else { + messageIdentifier = + database.messageDao().getOrCreateMessage(chat, transformation); + } + } catch (final IllegalStateException e) { + LOGGER.warn("Could not get message identifier", e); + return false; } + database.messageDao().insertMessageContent(messageIdentifier.version, contents); + return true; } return true; } diff --git a/src/main/java/im/conversations/android/xmpp/model/correction/Replace.java b/src/main/java/im/conversations/android/xmpp/model/correction/Replace.java index 890311b70..9febd8779 100644 --- a/src/main/java/im/conversations/android/xmpp/model/correction/Replace.java +++ b/src/main/java/im/conversations/android/xmpp/model/correction/Replace.java @@ -1,10 +1,18 @@ 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; +@XmlElement(namespace = Namespace.LAST_MESSAGE_CORRECTION) public class Replace extends Extension { public Replace() { super(Replace.class); } + + public String getId() { + return Strings.emptyToNull(this.getAttribute("id")); + } } diff --git a/src/main/java/im/conversations/android/xmpp/model/occupant/OccupantId.java b/src/main/java/im/conversations/android/xmpp/model/occupant/OccupantId.java new file mode 100644 index 000000000..2a4e3a82c --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/occupant/OccupantId.java @@ -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")); + } +}