diff --git a/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/schemas/im.conversations.android.database.ConversationsDatabase/1.json index f77abfa77..bb6820596 100644 --- a/schemas/im.conversations.android.database.ConversationsDatabase/1.json +++ b/schemas/im.conversations.android.database.ConversationsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "b6a7be8218829fd38f51dcd76cb9cccd", + "identityHash": "219a451e9a1889222b7549c8b3c0a5b3", "entities": [ { "tableName": "account", @@ -1420,7 +1420,7 @@ }, { "tableName": "message", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `chatId` INTEGER NOT NULL, `receivedAt` INTEGER, `sentAt` INTEGER, `outgoing` INTEGER NOT NULL, `toBare` TEXT, `toResource` TEXT, `fromBare` TEXT, `fromResource` TEXT, `occupantId` TEXT, `messageId` TEXT, `stanzaId` TEXT, `stanzaIdVerified` INTEGER NOT NULL, `latestVersion` INTEGER, `acknowledged` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chat`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`latestVersion`) REFERENCES `message_version`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `chatId` INTEGER NOT NULL, `receivedAt` INTEGER, `sentAt` INTEGER, `outgoing` INTEGER NOT NULL, `toBare` TEXT, `toResource` TEXT, `fromBare` TEXT, `fromResource` TEXT, `occupantId` TEXT, `messageId` TEXT, `stanzaId` TEXT, `stanzaIdVerified` INTEGER NOT NULL, `latestVersion` INTEGER, `acknowledged` INTEGER NOT NULL, `inReplyToMessageId` TEXT, `inReplyToStanzaId` TEXT, `inReplyToMessageEntityId` INTEGER, FOREIGN KEY(`chatId`) REFERENCES `chat`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`latestVersion`) REFERENCES `message_version`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToMessageEntityId`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", "fields": [ { "fieldPath": "id", @@ -1511,6 +1511,24 @@ "columnName": "acknowledged", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "inReplyToMessageId", + "columnName": "inReplyToMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToStanzaId", + "columnName": "inReplyToStanzaId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToMessageEntityId", + "columnName": "inReplyToMessageEntityId", + "affinity": "INTEGER", + "notNull": false } ], "primaryKey": { @@ -1537,6 +1555,15 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_message_latestVersion` ON `${TABLE_NAME}` (`latestVersion`)" + }, + { + "name": "index_message_inReplyToMessageEntityId", + "unique": false, + "columnNames": [ + "inReplyToMessageEntityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_inReplyToMessageEntityId` ON `${TABLE_NAME}` (`inReplyToMessageEntityId`)" } ], "foreignKeys": [ @@ -1561,6 +1588,17 @@ "referencedColumns": [ "id" ] + }, + { + "table": "message", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToMessageEntityId" + ], + "referencedColumns": [ + "id" + ] } ] }, @@ -2228,7 +2266,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6a7be8218829fd38f51dcd76cb9cccd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '219a451e9a1889222b7549c8b3c0a5b3')" ] } } \ No newline at end of file diff --git a/src/androidTest/java/im/conversations/android/xmpp/TransformationTest.java b/src/androidTest/java/im/conversations/android/xmpp/TransformationTest.java index a891a8d55..fb3d6e020 100644 --- a/src/androidTest/java/im/conversations/android/xmpp/TransformationTest.java +++ b/src/androidTest/java/im/conversations/android/xmpp/TransformationTest.java @@ -9,6 +9,7 @@ import eu.siacs.conversations.xmpp.Jid; import im.conversations.android.IDs; import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.entity.AccountEntity; +import im.conversations.android.database.model.EmbeddedMessage; import im.conversations.android.database.model.Modification; import im.conversations.android.transformer.Transformation; import im.conversations.android.transformer.Transformer; @@ -16,6 +17,7 @@ import im.conversations.android.xmpp.model.correction.Replace; import im.conversations.android.xmpp.model.jabber.Body; import im.conversations.android.xmpp.model.reactions.Reaction; import im.conversations.android.xmpp.model.reactions.Reactions; +import im.conversations.android.xmpp.model.reply.Reply; import im.conversations.android.xmpp.model.stanza.Message; import java.time.Instant; import java.util.concurrent.ExecutionException; @@ -371,4 +373,37 @@ public class TransformationTest { Assert.assertEquals( "Please give me a thumbs up", Iterables.getOnlyElement(dbMessage.contents).body); } + + @Test + public void inReplyTo() { + final var m1 = new Message(); + m1.setId("1"); + m1.setTo(ACCOUNT); + m1.setFrom(REMOTE.withResource("junit")); + m1.addExtension(new Body("Hi. How are you?")); + + this.transformer.transform(Transformation.of(m1, Instant.now(), REMOTE, "stanza-a", null)); + + final var m2 = new Message(); + m2.setId("2"); + m2.setTo(REMOTE); + m2.setFrom(ACCOUNT); + m2.addExtension(new Body("I am fine.")); + final var reply = m2.addExtension(new Reply()); + reply.setId("1"); + reply.setTo(REMOTE); + + this.transformer.transform(Transformation.of(m2, Instant.now(), REMOTE, "stanza-b", null)); + + final var messages = database.messageDao().getMessages(1L); + Assert.assertEquals(2, messages.size()); + final var response = Iterables.get(messages, 1); + Assert.assertNotNull(response.inReplyToMessageEntityId); + final EmbeddedMessage embeddedMessage = response.inReplyTo; + Assert.assertNotNull(embeddedMessage); + Assert.assertEquals(REMOTE, embeddedMessage.fromBare); + Assert.assertEquals(1L, embeddedMessage.contents.size()); + Assert.assertEquals( + "Hi. How are you?", Iterables.getOnlyElement(embeddedMessage.contents).body); + } } diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index fdf514cd2..8493e5b3e 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -70,7 +70,6 @@ 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 REACTIONS = "urn:xmpp:reactions:0"; 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"; @@ -88,8 +87,10 @@ public final class Namespace { public static final String PUB_SUB_PERSISTENT_ITEMS = PUB_SUB + "#persistent-items"; public static final String PUB_SUB_PUBLISH_OPTIONS = PUB_SUB + "#publish-options"; public static final String PUSH = "urn:xmpp:push:0"; + public static final String REACTIONS = "urn:xmpp:reactions:0"; public static final String REGISTER = "jabber:iq:register"; public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register"; + public static final String REPLY = "urn:xmpp:reply:0"; public static final String ROSTER = "jabber:iq:roster"; public static final String SASL = "urn:ietf:params:xml:ns:xmpp-sasl"; public static final String SASL_2 = "urn:xmpp:sasl:2"; diff --git a/src/main/java/eu/siacs/conversations/xmpp/InvalidJid.java b/src/main/java/eu/siacs/conversations/xmpp/InvalidJid.java index aae86ad96..78a647342 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/InvalidJid.java +++ b/src/main/java/eu/siacs/conversations/xmpp/InvalidJid.java @@ -135,7 +135,7 @@ public class InvalidJid implements Jid { } public static boolean isValid(Jid jid) { - return !(jid != null && jid instanceof InvalidJid); + return !(jid instanceof InvalidJid); } public static boolean invalid(final Jid jid) { 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 d5d5b0cce..7c3f6e85f 100644 --- a/src/main/java/im/conversations/android/database/dao/MessageDao.java +++ b/src/main/java/im/conversations/android/database/dao/MessageDao.java @@ -399,8 +399,45 @@ public abstract class MessageDao { @Query( "SELECT message.id as" + " id,sentAt,outgoing,toBare,toResource,fromBare,fromResource,modification,latestVersion" - + " as version FROM message JOIN message_version ON" + + " as version,inReplyToMessageEntityId FROM message JOIN message_version ON" + " message.latestVersion=message_version.id WHERE message.chatId=:chatId AND" + " latestVersion IS NOT NULL ORDER BY message.receivedAt") public abstract List getMessages(long chatId); + + public void setInReplyTo( + ChatIdentifier chat, + MessageIdentifier messageIdentifier, + Message.Type messageType, + final Jid to, + String inReplyTo) { + if (messageType == Message.Type.GROUPCHAT) { + final Long messageEntityId = getMessageByStanzaId(chat.id, inReplyTo); + setInReplyToStanzaId(messageIdentifier.id, inReplyTo, messageEntityId); + } else { + final Long messageEntityId = getMessageByMessageId(chat.id, to.asBareJid(), inReplyTo); + setInReplyToMessageId(messageIdentifier.id, inReplyTo, messageEntityId); + } + } + + @Query( + "UPDATE message SET" + + " inReplyToMessageId=null,inReplyToStanzaId=:stanzaId,inReplyToMessageEntityId=:inReplyToMessageEntityId" + + " WHERE id=:id") + protected abstract void setInReplyToStanzaId( + final long id, String stanzaId, long inReplyToMessageEntityId); + + @Query( + "UPDATE message SET" + + " inReplyToMessageId=:messageId,inReplyToStanzaId=null,inReplyToMessageEntityId=:inReplyToMessageEntityId" + + " WHERE id=:id") + protected abstract void setInReplyToMessageId( + final long id, String messageId, long inReplyToMessageEntityId); + + @Query( + "SELECT id FROM message WHERE chatId=:chatId AND fromBare=:fromBare AND" + + " messageId=:messageId") + protected abstract Long getMessageByMessageId(long chatId, Jid fromBare, String messageId); + + @Query("SELECT id FROM message WHERE chatId=:chatId AND stanzaId=:stanzaId") + protected abstract Long getMessageByStanzaId(long chatId, String stanzaId); } 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 f09aaed88..9bde72179 100644 --- a/src/main/java/im/conversations/android/database/entity/MessageEntity.java +++ b/src/main/java/im/conversations/android/database/entity/MessageEntity.java @@ -24,8 +24,17 @@ import java.util.Objects; parentColumns = {"id"}, childColumns = {"latestVersion"}, onDelete = ForeignKey.CASCADE), + @ForeignKey( + entity = MessageEntity.class, + parentColumns = {"id"}, + childColumns = {"inReplyToMessageEntityId"}, + onDelete = ForeignKey.SET_NULL), }, - indices = {@Index(value = "chatId"), @Index(value = "latestVersion")}) + indices = { + @Index(value = "chatId"), + @Index(value = "latestVersion"), + @Index("inReplyToMessageEntityId") + }) public class MessageEntity { @PrimaryKey(autoGenerate = true) @@ -55,6 +64,10 @@ public class MessageEntity { public boolean acknowledged = false; + public String inReplyToMessageId; + public String inReplyToStanzaId; + @Nullable public Long inReplyToMessageEntityId; + public static MessageEntity of(final long chatId, final Transformation transformation) { final var entity = new MessageEntity(); entity.chatId = chatId; diff --git a/src/main/java/im/conversations/android/database/model/EmbeddedMessage.java b/src/main/java/im/conversations/android/database/model/EmbeddedMessage.java new file mode 100644 index 000000000..59d21b2b4 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/EmbeddedMessage.java @@ -0,0 +1,23 @@ +package im.conversations.android.database.model; + +import androidx.room.Relation; +import eu.siacs.conversations.xmpp.Jid; +import im.conversations.android.database.entity.MessageContentEntity; +import java.time.Instant; +import java.util.List; + +public class EmbeddedMessage { + + public long id; + public Jid fromBare; + public String fromResource; + public Instant sentAt; + + public Long latestVersion; + + @Relation( + entity = MessageContentEntity.class, + parentColumn = "latestVersion", + entityColumn = "messageVersionId") + public List contents; +} diff --git a/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java b/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java index cb237738c..22ccf2ddd 100644 --- a/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java +++ b/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java @@ -6,6 +6,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Multimaps; import eu.siacs.conversations.xmpp.Jid; import im.conversations.android.database.entity.MessageContentEntity; +import im.conversations.android.database.entity.MessageEntity; import im.conversations.android.database.entity.MessageReactionEntity; import java.time.Instant; import java.util.Collection; @@ -28,6 +29,13 @@ public class MessageWithContentReactions { public Modification modification; public long version; + public Long inReplyToMessageEntityId; + + @Relation( + entity = MessageEntity.class, + parentColumn = "inReplyToMessageEntityId", + entityColumn = "id") + public EmbeddedMessage inReplyTo; @Relation( entity = MessageContentEntity.class, diff --git a/src/main/java/im/conversations/android/transformer/Transformation.java b/src/main/java/im/conversations/android/transformer/Transformation.java index 801615f12..183b08bd6 100644 --- a/src/main/java/im/conversations/android/transformer/Transformation.java +++ b/src/main/java/im/conversations/android/transformer/Transformation.java @@ -17,6 +17,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.reactions.Reactions; +import im.conversations.android.xmpp.model.reply.Reply; import im.conversations.android.xmpp.model.stanza.Message; import java.time.Instant; import java.util.Arrays; @@ -36,7 +37,8 @@ public class Transformation { MultiUserChat.class, Displayed.class, Replace.class, - Reactions.class); + Reactions.class, + Reply.class); public final Instant receivedAt; public final Jid to; diff --git a/src/main/java/im/conversations/android/transformer/Transformer.java b/src/main/java/im/conversations/android/transformer/Transformer.java index 92707b409..11190bc59 100644 --- a/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/src/main/java/im/conversations/android/transformer/Transformer.java @@ -4,6 +4,7 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import eu.siacs.conversations.xmpp.InvalidJid; import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.ChatIdentifier; @@ -19,6 +20,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.reactions.Reactions; +import im.conversations.android.xmpp.model.reply.Reply; import im.conversations.android.xmpp.model.stanza.Message; import java.util.Arrays; import java.util.Collection; @@ -113,6 +115,14 @@ public class Transformer { return false; } database.messageDao().insertMessageContent(messageIdentifier.version, contents); + final var reply = transformation.getExtension(Reply.class); + if (Objects.nonNull(reply) + && Objects.nonNull(reply.getId()) + && InvalidJid.isValid(reply.getTo())) { + database.messageDao() + .setInReplyTo( + chat, messageIdentifier, messageType, reply.getTo(), reply.getId()); + } return true; } return true; diff --git a/src/main/java/im/conversations/android/xmpp/model/reply/Reply.java b/src/main/java/im/conversations/android/xmpp/model/reply/Reply.java new file mode 100644 index 000000000..f3eb4a25e --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/reply/Reply.java @@ -0,0 +1,30 @@ +package im.conversations.android.xmpp.model.reply; + +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement(namespace = Namespace.REPLY) +public class Reply extends Extension { + + public Reply() { + super(Reply.class); + } + + public Jid getTo() { + return this.getAttributeAsJid("to"); + } + + public String getId() { + return this.getAttribute("id"); + } + + public void setTo(final Jid to) { + this.setAttribute("to", to); + } + + public void setId(final String id) { + this.setAttribute("id", id); + } +}