From 9b62861a649745afe5e699eece972e198a8c614a Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Fri, 10 Feb 2023 14:10:51 +0100 Subject: [PATCH] store messages in database --- .../1.json | 68 +++++++++--- .../eu/siacs/conversations/xml/Namespace.java | 5 +- .../database/ConversationsDatabase.java | 7 +- .../android/database/dao/ChatDao.java | 54 +++++++++ .../android/database/dao/MessageDao.java | 105 +++++++++++++++++- ...tEntity.java => MessageContentEntity.java} | 16 ++- .../database/entity/MessageEntity.java | 51 +++++++-- .../database/entity/MessageVersionEntity.java | 35 ++++-- .../database/model/ChatIdentifier.java | 18 +++ .../database/model/MessageContent.java | 27 +++++ .../database/model/MessageIdentifier.java | 37 ++++++ .../android/transformer/Transformation.java | 60 +++++++++- .../transformer/TransformationFactory.java | 33 ++++++ .../android/transformer/Transformer.java | 104 +++++++++++++++-- .../android/xmpp/Timestamps.java | 44 ++++++++ .../android/xmpp/manager/ArchiveManager.java | 20 +++- .../android/xmpp/model/DeliveryReceipt.java | 10 ++ .../android/xmpp/model/axolotl/Encrypted.java | 4 + .../android/xmpp/model/delay/Delay.java | 29 +++++ .../android/xmpp/model/jabber/Body.java | 4 + .../android/xmpp/{ => model}/mam/Result.java | 2 +- .../xmpp/{ => model}/mam/package-info.java | 2 +- .../android/xmpp/model/markers/Received.java | 8 +- .../xmpp/model/muc/user/MultiUserChat.java | 12 ++ .../xmpp/model/muc/user/package-info.java | 5 + .../android/xmpp/model/receipts/Received.java | 8 +- .../xmpp/processor/MessageProcessor.java | 11 +- .../android/xmpp/TimestampTest.java | 44 ++++++++ 28 files changed, 752 insertions(+), 71 deletions(-) create mode 100644 src/main/java/im/conversations/android/database/dao/ChatDao.java rename src/main/java/im/conversations/android/database/entity/{MessagePartEntity.java => MessageContentEntity.java} (60%) create mode 100644 src/main/java/im/conversations/android/database/model/ChatIdentifier.java create mode 100644 src/main/java/im/conversations/android/database/model/MessageContent.java create mode 100644 src/main/java/im/conversations/android/database/model/MessageIdentifier.java create mode 100644 src/main/java/im/conversations/android/transformer/TransformationFactory.java create mode 100644 src/main/java/im/conversations/android/xmpp/Timestamps.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/DeliveryReceipt.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/delay/Delay.java rename src/main/java/im/conversations/android/xmpp/{ => model}/mam/Result.java (91%) rename src/main/java/im/conversations/android/xmpp/{ => model}/mam/package-info.java (76%) create mode 100644 src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java create mode 100644 src/main/java/im/conversations/android/xmpp/model/muc/user/package-info.java create mode 100644 src/test/java/im/conversations/android/xmpp/TimestampTest.java diff --git a/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/schemas/im.conversations.android.database.ConversationsDatabase/1.json index 8f46a5878..8a2506a47 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": "2972255ca35c75ece48909471313d20a", + "identityHash": "03075d3509cc0d79cf5e733cff6b71fd", "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, `bareTo` TEXT, `toResource` TEXT, `bareFrom` TEXT, `fromResource` TEXT, `occupantId` TEXT, `messageId` TEXT, `stanzaId` TEXT, `acknowledged` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chat`(`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, 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 )", "fields": [ { "fieldPath": "id", @@ -1447,8 +1447,14 @@ "notNull": false }, { - "fieldPath": "bareTo", - "columnName": "bareTo", + "fieldPath": "outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBare", + "columnName": "toBare", "affinity": "TEXT", "notNull": false }, @@ -1459,8 +1465,8 @@ "notNull": false }, { - "fieldPath": "bareFrom", - "columnName": "bareFrom", + "fieldPath": "fromBare", + "columnName": "fromBare", "affinity": "TEXT", "notNull": false }, @@ -1488,6 +1494,18 @@ "affinity": "TEXT", "notNull": false }, + { + "fieldPath": "stanzaIdVerified", + "columnName": "stanzaIdVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latestVersion", + "columnName": "latestVersion", + "affinity": "INTEGER", + "notNull": false + }, { "fieldPath": "acknowledged", "columnName": "acknowledged", @@ -1510,6 +1528,15 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_message_chatId` ON `${TABLE_NAME}` (`chatId`)" + }, + { + "name": "index_message_latestVersion", + "unique": false, + "columnNames": [ + "latestVersion" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_latestVersion` ON `${TABLE_NAME}` (`latestVersion`)" } ], "foreignKeys": [ @@ -1523,11 +1550,22 @@ "referencedColumns": [ "id" ] + }, + { + "table": "message_version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "latestVersion" + ], + "referencedColumns": [ + "id" + ] } ] }, { - "tableName": "message_part", + "tableName": "message_content", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `messageVersionId` INTEGER NOT NULL, `language` TEXT, `type` TEXT, `body` TEXT, `url` TEXT, FOREIGN KEY(`messageVersionId`) REFERENCES `message_version`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { @@ -1575,13 +1613,13 @@ }, "indices": [ { - "name": "index_message_part_messageVersionId", + "name": "index_message_content_messageVersionId", "unique": false, "columnNames": [ "messageVersionId" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_message_part_messageVersionId` ON `${TABLE_NAME}` (`messageVersionId`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_content_messageVersionId` ON `${TABLE_NAME}` (`messageVersionId`)" } ], "foreignKeys": [ @@ -1600,7 +1638,7 @@ }, { "tableName": "message_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `messageEntityId` INTEGER NOT NULL, `messageId` TEXT, `stanzaId` TEXT, `modification` TEXT, `modifiedBy` TEXT, `modifiedByResource` TEXT, `occupantId` TEXT, `receivedAt` INTEGER, FOREIGN KEY(`messageId`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `messageEntityId` INTEGER NOT NULL, `messageId` TEXT, `stanzaId` TEXT, `modification` TEXT, `modifiedBy` TEXT, `modifiedByResource` TEXT, `occupantId` TEXT, `receivedAt` INTEGER, FOREIGN KEY(`messageEntityId`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -1665,13 +1703,13 @@ }, "indices": [ { - "name": "index_message_version_messageId", + "name": "index_message_version_messageEntityId", "unique": false, "columnNames": [ - "messageId" + "messageEntityId" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_message_version_messageId` ON `${TABLE_NAME}` (`messageId`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_version_messageEntityId` ON `${TABLE_NAME}` (`messageEntityId`)" } ], "foreignKeys": [ @@ -1680,7 +1718,7 @@ "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ - "messageId" + "messageEntityId" ], "referencedColumns": [ "id" @@ -2112,7 +2150,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, '2972255ca35c75ece48909471313d20a')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '03075d3509cc0d79cf5e733cff6b71fd')" ] } } \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 58e1c725c..f048f8491 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -24,6 +24,7 @@ public final class Namespace { public static final String CONFERENCE = "jabber:x:conference"; public static final String CSI = "urn:xmpp:csi:0"; public static final String DATA = "jabber:x:data"; + public static final String DELAY = "urn:xmpp:delay"; public static final String DELIVERY_RECEIPTS = "urn:xmpp:receipts"; public static final String DISCO_INFO = "http://jabber.org/protocol/disco#info"; public static final String DISCO_ITEMS = "http://jabber.org/protocol/disco#items"; @@ -65,6 +66,7 @@ public final class Namespace { public static final String JINGLE_TRANSPORTS_S5B = "urn:xmpp:jingle:transports:s5b:1"; public static final String JINGLE_TRANSPORT_ICE_UDP = "urn:xmpp:jingle:transports:ice-udp:1"; public static final String LAST_MESSAGE_CORRECTION = "urn:xmpp:message-correct:0"; + public static final String MESSAGE_ARCHIVE_MANAGEMENT = "urn:xmpp:mam:2"; 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"; @@ -74,9 +76,9 @@ public final class Namespace { public static final String PARS = "urn:xmpp:pars:0"; public static final String PING = "urn:xmpp:ping"; public static final String PUBSUB = "http://jabber.org/protocol/pubsub"; + public static final String PUBSUB_ERROR = PUBSUB + "#errors"; public static final String PUBSUB_OWNER = PUBSUB + "#owner"; public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options"; - public static final String PUBSUB_ERROR = PUBSUB + "#errors"; public static final String PUB_SUB = "http://jabber.org/protocol/pubsub"; public static final String PUB_SUB_ERRORS = PUB_SUB + "#errors"; public static final String PUB_SUB_EVENT = PUB_SUB + "#event"; @@ -91,7 +93,6 @@ public final class Namespace { public static final String SASL_2 = "urn:xmpp:sasl:2"; public static final String STANZAS = "urn:ietf:params:xml:ns:xmpp-stanzas"; public static final String STANZA_IDS = "urn:xmpp:sid:0"; - public static final String MESSAGE_ARCHIVE_MANAGEMENT = "urn:xmpp:mam:2"; public static final String STREAMS = "http://etherx.jabber.org/streams"; public static final String STREAM_MANAGEMENT = "urn:xmpp:sm:3"; public static final String SYNCHRONIZATION = "im.quicksy.synchronization:0"; diff --git a/src/main/java/im/conversations/android/database/ConversationsDatabase.java b/src/main/java/im/conversations/android/database/ConversationsDatabase.java index 6af016032..04a57bc0b 100644 --- a/src/main/java/im/conversations/android/database/ConversationsDatabase.java +++ b/src/main/java/im/conversations/android/database/ConversationsDatabase.java @@ -10,6 +10,7 @@ import im.conversations.android.database.dao.AvatarDao; import im.conversations.android.database.dao.AxolotlDao; import im.conversations.android.database.dao.BlockingDao; import im.conversations.android.database.dao.BookmarkDao; +import im.conversations.android.database.dao.ChatDao; import im.conversations.android.database.dao.DiscoDao; import im.conversations.android.database.dao.MessageDao; import im.conversations.android.database.dao.NickDao; @@ -35,8 +36,8 @@ import im.conversations.android.database.entity.DiscoExtensionFieldValueEntity; import im.conversations.android.database.entity.DiscoFeatureEntity; import im.conversations.android.database.entity.DiscoIdentityEntity; import im.conversations.android.database.entity.DiscoItemEntity; +import im.conversations.android.database.entity.MessageContentEntity; import im.conversations.android.database.entity.MessageEntity; -import im.conversations.android.database.entity.MessagePartEntity; import im.conversations.android.database.entity.MessageVersionEntity; import im.conversations.android.database.entity.NickEntity; import im.conversations.android.database.entity.PresenceEntity; @@ -67,7 +68,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity; DiscoIdentityEntity.class, DiscoItemEntity.class, MessageEntity.class, - MessagePartEntity.class, + MessageContentEntity.class, MessageVersionEntity.class, NickEntity.class, PresenceEntity.class, @@ -107,6 +108,8 @@ public abstract class ConversationsDatabase extends RoomDatabase { public abstract BookmarkDao bookmarkDao(); + public abstract ChatDao chatDao(); + public abstract DiscoDao discoDao(); public abstract MessageDao messageDao(); diff --git a/src/main/java/im/conversations/android/database/dao/ChatDao.java b/src/main/java/im/conversations/android/database/dao/ChatDao.java new file mode 100644 index 000000000..79c87496a --- /dev/null +++ b/src/main/java/im/conversations/android/database/dao/ChatDao.java @@ -0,0 +1,54 @@ +package im.conversations.android.database.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Transaction; +import eu.siacs.conversations.xmpp.Jid; +import im.conversations.android.database.entity.ChatEntity; +import im.conversations.android.database.model.Account; +import im.conversations.android.database.model.ChatIdentifier; +import im.conversations.android.database.model.ChatType; +import im.conversations.android.xmpp.model.stanza.Message; +import java.util.Arrays; + +@Dao +public abstract class ChatDao { + + @Transaction + public ChatIdentifier getOrCreateChat( + final Account account, + final Jid remote, + final Message.Type messageType, + final boolean multiUserChat) { + final ChatType chatType; + if (multiUserChat + && Arrays.asList(Message.Type.CHAT, Message.Type.NORMAL).contains(messageType)) { + chatType = ChatType.MUC_PM; + } else if (messageType == Message.Type.GROUPCHAT) { + chatType = ChatType.MUC; + } else { + chatType = ChatType.INDIVIDUAL; + } + final Jid address = chatType == ChatType.MUC_PM ? remote : remote.asBareJid(); + final ChatIdentifier existing = get(account.id, address); + if (existing != null) { + return existing; + } + final var entity = new ChatEntity(); + entity.accountId = account.id; + entity.address = address.toEscapedString(); + entity.type = chatType; + entity.archived = true; + final long id = insert(entity); + return new ChatIdentifier(id, address, chatType, true); + } + + @Query( + "SELECT id,address,type,archived FROM chat WHERE accountId=:accountId AND" + + " address=:address") + protected abstract ChatIdentifier get(final long accountId, final Jid address); + + @Insert + protected abstract long insert(ChatEntity chatEntity); +} 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 941d62ca2..837c0755a 100644 --- a/src/main/java/im/conversations/android/database/dao/MessageDao.java +++ b/src/main/java/im/conversations/android/database/dao/MessageDao.java @@ -2,24 +2,38 @@ package im.conversations.android.database.dao; import androidx.annotation.NonNull; import androidx.room.Dao; +import androidx.room.Insert; import androidx.room.Query; +import androidx.room.Transaction; +import com.google.common.base.Preconditions; +import com.google.common.collect.Lists; 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.MessageVersionEntity; 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.Modification; +import im.conversations.android.transformer.Transformation; +import java.util.Collection; +import java.util.List; @Dao public abstract class MessageDao { @Query( - "UPDATE message SET acknowledged=1 WHERE messageId=:messageId AND bareTo=:bareTo AND" + "UPDATE message SET acknowledged=1 WHERE messageId=:messageId AND toBare=:toBare AND" + " toResource=NULL AND chatId IN (SELECT id FROM chat WHERE accountId=:account)") - abstract int acknowledge(long account, String messageId, final String bareTo); + abstract int acknowledge(long account, String messageId, final String toBare); @Query( - "UPDATE message SET acknowledged=1 WHERE messageId=:messageId AND bareTo=:bareTo AND" + "UPDATE message SET acknowledged=1 WHERE messageId=:messageId AND toBare=:toBare AND" + " toResource=:toResource AND chatId IN (SELECT id FROM chat WHERE" + " accountId=:account)") abstract int acknowledge( - long account, final String messageId, final String bareTo, final String toResource); + long account, final String messageId, final String toBare, final String toResource); public boolean acknowledge( final Account account, @NonNull final String messageId, @NonNull final Jid to) { @@ -36,4 +50,87 @@ public abstract class MessageDao { > 0; } } + + @Transaction + public MessageIdentifier getOrCreateMessage( + ChatIdentifier chatIdentifier, final Transformation transformation) { + final MessageIdentifier messageIdentifier = + get( + chatIdentifier.id, + transformation.fromBare(), + transformation.stanzaId, + transformation.messageId); + if (messageIdentifier != null) { + if (messageIdentifier.isStub()) { + // TODO create version + // TODO fill up information + return messageIdentifier; + } else { + throw new IllegalStateException( + String.format( + "A message with stanzaId '%s' and messageId '%s' from %s already" + + " exists", + transformation.stanzaId, + transformation.messageId, + transformation.from)); + } + } + final MessageEntity entity = MessageEntity.of(chatIdentifier.id, transformation); + final long messageEntityId = insert(entity); + final long messageVersionId = + insert( + MessageVersionEntity.of( + messageEntityId, Modification.ORIGINAl, transformation)); + setLatestMessageId(messageEntityId, messageVersionId); + return new MessageIdentifier( + messageEntityId, + transformation.stanzaId, + transformation.messageId, + transformation.fromBare(), + messageVersionId); + } + + @Insert + protected abstract long insert(MessageEntity messageEntity); + + @Insert + protected abstract long insert(MessageVersionEntity messageVersionEntity); + + @Query("UPDATE message SET latestVersion=:messageVersionId WHERE id=:messageEntityId") + protected abstract void setLatestMessageId( + final long messageEntityId, final long messageVersionId); + + public Long getOrCreateStub(final Transformation transformation) { + // TODO look up where parentId matches messageId (or stanzaId for group chats) + + // when creating stub either set from (correction) or don’t (other attachment) + + 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 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"); + Preconditions.checkArgument( + contents.size() > 0, + "If you are trying to insert empty contents something went wrong"); + insertMessageContent( + Lists.transform(contents, c -> MessageContentEntity.of(latestVersion, c))); + } + + @Insert + protected abstract void insertMessageContent(Collection contentEntities); } diff --git a/src/main/java/im/conversations/android/database/entity/MessagePartEntity.java b/src/main/java/im/conversations/android/database/entity/MessageContentEntity.java similarity index 60% rename from src/main/java/im/conversations/android/database/entity/MessagePartEntity.java rename to src/main/java/im/conversations/android/database/entity/MessageContentEntity.java index 160f0b2ef..220a46be0 100644 --- a/src/main/java/im/conversations/android/database/entity/MessagePartEntity.java +++ b/src/main/java/im/conversations/android/database/entity/MessageContentEntity.java @@ -5,10 +5,11 @@ import androidx.room.Entity; import androidx.room.ForeignKey; import androidx.room.Index; import androidx.room.PrimaryKey; +import im.conversations.android.database.model.MessageContent; import im.conversations.android.database.model.PartType; @Entity( - tableName = "message_part", + tableName = "message_content", foreignKeys = @ForeignKey( entity = MessageVersionEntity.class, @@ -16,7 +17,7 @@ import im.conversations.android.database.model.PartType; childColumns = {"messageVersionId"}, onDelete = ForeignKey.CASCADE), indices = {@Index(value = "messageVersionId")}) -public class MessagePartEntity { +public class MessageContentEntity { @PrimaryKey(autoGenerate = true) public Long id; @@ -30,4 +31,15 @@ public class MessagePartEntity { public String body; public String url; + + public static MessageContentEntity of( + final long messageVersionId, final MessageContent content) { + final var entity = new MessageContentEntity(); + entity.messageVersionId = messageVersionId; + entity.language = content.language; + entity.type = content.type; + entity.body = content.body; + entity.url = content.url; + return entity; + } } 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 8f69e18a9..be76cb7f6 100644 --- a/src/main/java/im/conversations/android/database/entity/MessageEntity.java +++ b/src/main/java/im/conversations/android/database/entity/MessageEntity.java @@ -1,21 +1,31 @@ package im.conversations.android.database.entity; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.room.Entity; import androidx.room.ForeignKey; import androidx.room.Index; import androidx.room.PrimaryKey; +import eu.siacs.conversations.xmpp.Jid; +import im.conversations.android.transformer.Transformation; import java.time.Instant; +import java.util.Objects; @Entity( tableName = "message", - foreignKeys = - @ForeignKey( - entity = ChatEntity.class, - parentColumns = {"id"}, - childColumns = {"chatId"}, - onDelete = ForeignKey.CASCADE), - indices = {@Index(value = "chatId")}) + foreignKeys = { + @ForeignKey( + entity = ChatEntity.class, + parentColumns = {"id"}, + childColumns = {"chatId"}, + onDelete = ForeignKey.CASCADE), + @ForeignKey( + entity = MessageVersionEntity.class, + parentColumns = {"id"}, + childColumns = {"latestVersion"}, + onDelete = ForeignKey.CASCADE), + }, + indices = {@Index(value = "chatId"), @Index(value = "latestVersion")}) public class MessageEntity { @PrimaryKey(autoGenerate = true) @@ -28,17 +38,36 @@ public class MessageEntity { public boolean outgoing; - public String bareTo; + public Jid toBare; public String toResource; - public String bareFrom; + public Jid fromBare; public String fromResource; public String occupantId; public String messageId; public String stanzaId; - // the stanza id might not be verified if this MessageEntity was created as a stub parent to attach reactions to or new versions (created by LMC etc) - public String stanzaIdVerified; + // the stanza id might not be verified if this MessageEntity was created as a stub parent to + // attach reactions to or new versions (created by LMC etc) + public boolean stanzaIdVerified; + + @Nullable public Long latestVersion; public boolean acknowledged = false; + + public static MessageEntity of(final long chatId, final Transformation transformation) { + final var entity = new MessageEntity(); + entity.chatId = chatId; + entity.receivedAt = transformation.receivedAt; + entity.sentAt = transformation.sentAt(); + entity.outgoing = transformation.outgoing(); + entity.toBare = transformation.toBare(); + entity.toResource = transformation.toResource(); + entity.fromBare = transformation.fromBare(); + entity.fromResource = transformation.fromResource(); + entity.messageId = transformation.messageId; + entity.stanzaId = transformation.stanzaId; + entity.stanzaIdVerified = Objects.nonNull(transformation.stanzaId); + return entity; + } } diff --git a/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java b/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java index afec5b14c..9a717b7bf 100644 --- a/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java +++ b/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java @@ -5,18 +5,21 @@ import androidx.room.Entity; import androidx.room.ForeignKey; import androidx.room.Index; import androidx.room.PrimaryKey; +import eu.siacs.conversations.xmpp.Jid; import im.conversations.android.database.model.Modification; +import im.conversations.android.transformer.Transformation; import java.time.Instant; @Entity( tableName = "message_version", - foreignKeys = - @ForeignKey( - entity = MessageEntity.class, - parentColumns = {"id"}, - childColumns = {"messageId"}, - onDelete = ForeignKey.CASCADE), - indices = {@Index(value = "messageId")}) + foreignKeys = { + @ForeignKey( + entity = MessageEntity.class, + parentColumns = {"id"}, + childColumns = {"messageEntityId"}, + onDelete = ForeignKey.CASCADE), + }, + indices = {@Index(value = "messageEntityId")}) public class MessageVersionEntity { @PrimaryKey(autoGenerate = true) @@ -26,14 +29,28 @@ public class MessageVersionEntity { public String messageId; public String stanzaId; public Modification modification; - public String modifiedBy; + public Jid modifiedBy; public String modifiedByResource; public String occupantId; - Instant receivedAt; + public Instant receivedAt; // the version order is determined by the receivedAt // the actual display time and display order comes from the parent MessageEntity // the original has a receivedAt = null and stanzaId = null and inherits it's timestamp from // it's parent + public static MessageVersionEntity of( + long messageEntityId, + final Modification modification, + final Transformation transformation) { + final var entity = new MessageVersionEntity(); + entity.messageEntityId = messageEntityId; + entity.messageId = transformation.messageId; + entity.stanzaId = transformation.stanzaId; + entity.modification = modification; + entity.modifiedBy = transformation.fromBare(); + entity.modifiedByResource = transformation.fromResource(); + entity.receivedAt = transformation.receivedAt; + return entity; + } } diff --git a/src/main/java/im/conversations/android/database/model/ChatIdentifier.java b/src/main/java/im/conversations/android/database/model/ChatIdentifier.java new file mode 100644 index 000000000..db1e6f6f0 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/ChatIdentifier.java @@ -0,0 +1,18 @@ +package im.conversations.android.database.model; + +import eu.siacs.conversations.xmpp.Jid; + +public class ChatIdentifier { + + public final long id; + public final Jid address; + public final ChatType type; + public final boolean archived; + + public ChatIdentifier(long id, Jid address, ChatType type, final boolean archived) { + this.id = id; + this.address = address; + this.type = type; + this.archived = archived; + } +} diff --git a/src/main/java/im/conversations/android/database/model/MessageContent.java b/src/main/java/im/conversations/android/database/model/MessageContent.java new file mode 100644 index 000000000..98ceb3ffc --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/MessageContent.java @@ -0,0 +1,27 @@ +package im.conversations.android.database.model; + +public class MessageContent { + + public final String language; + + public final PartType type; + + public final String body; + + public final String url; + + public MessageContent(String language, PartType type, String body, String url) { + this.language = language; + this.type = type; + this.body = body; + this.url = url; + } + + public static MessageContent text(final String body, final String language) { + return new MessageContent(language, PartType.TEXT, body, null); + } + + public static MessageContent file(final String url) { + return new MessageContent(null, PartType.FILE, null, url); + } +} diff --git a/src/main/java/im/conversations/android/database/model/MessageIdentifier.java b/src/main/java/im/conversations/android/database/model/MessageIdentifier.java new file mode 100644 index 000000000..d0fddf428 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/MessageIdentifier.java @@ -0,0 +1,37 @@ +package im.conversations.android.database.model; + +import com.google.common.base.MoreObjects; +import eu.siacs.conversations.xmpp.Jid; + +public class MessageIdentifier { + + public final long id; + public final String stanzaId; + public final String messageId; + public final Jid fromBare; + public final Long latestVersion; + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("stanzaId", stanzaId) + .add("messageId", messageId) + .add("fromBare", fromBare) + .add("latestVersion", latestVersion) + .toString(); + } + + public MessageIdentifier( + long id, String stanzaId, String messageId, Jid fromBare, Long latestVersion) { + this.id = id; + this.stanzaId = stanzaId; + this.messageId = messageId; + this.fromBare = fromBare; + this.latestVersion = latestVersion; + } + + public boolean isStub() { + return this.latestVersion == null; + } +} diff --git a/src/main/java/im/conversations/android/transformer/Transformation.java b/src/main/java/im/conversations/android/transformer/Transformation.java index 362c8c72d..23f9491aa 100644 --- a/src/main/java/im/conversations/android/transformer/Transformation.java +++ b/src/main/java/im/conversations/android/transformer/Transformation.java @@ -1,16 +1,20 @@ package im.conversations.android.transformer; +import androidx.annotation.NonNull; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import eu.siacs.conversations.xmpp.Jid; +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.jabber.Body; import im.conversations.android.xmpp.model.jabber.Thread; +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.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -18,10 +22,18 @@ import java.util.List; public class Transformation { private static final List> EXTENSION_FOR_TRANSFORMATION = - Arrays.asList(Body.class, Thread.class, Encrypted.class, OutOfBandData.class); + Arrays.asList( + Body.class, + Thread.class, + Encrypted.class, + OutOfBandData.class, + DeliveryReceipt.class, + MultiUserChat.class); + public final Instant receivedAt; public final Jid to; public final Jid from; + public final Jid remote; public final Message.Type type; public final String messageId; public final String stanzaId; @@ -31,15 +43,19 @@ public class Transformation { public final Collection deliveryReceiptRequests; private Transformation( + final Instant receivedAt, final Jid to, final Jid from, + final Jid remote, final Message.Type type, final String messageId, final String stanzaId, final List extensions, final Collection deliveryReceiptRequests) { + this.receivedAt = receivedAt; this.to = to; this.from = from; + this.remote = remote; this.type = type; this.messageId = messageId; this.stanzaId = stanzaId; @@ -51,6 +67,32 @@ public class Transformation { return this.extensions.size() > 0; } + public Jid fromBare() { + return from == null ? null : from.asBareJid(); + } + + public String fromResource() { + return from == null ? null : from.getResource(); + } + + public Jid toBare() { + return to == null ? null : to.asBareJid(); + } + + public String toResource() { + return to == null ? null : to.getResource(); + } + + public Instant sentAt() { + // TODO get Delay that matches sender; return receivedAt if not found + return receivedAt; + } + + public boolean outgoing() { + // TODO handle case for self addressed (to == from) + return remote.asBareJid().equals(toBare()); + } + public E getExtension(final Class clazz) { final var extension = Iterables.find(this.extensions, clazz::isInstance, null); return extension == null ? null : clazz.cast(extension); @@ -61,7 +103,11 @@ public class Transformation { Collections2.filter(this.extensions, clazz::isInstance), clazz::cast); } - public static Transformation of(final Message message, final String stanzaId) { + public static Transformation of( + @NonNull final Message message, + @NonNull final Instant receivedAt, + @NonNull final Jid remote, + final String stanzaId) { final var to = message.getTo(); final var from = message.getFrom(); final var type = message.getType(); @@ -72,6 +118,14 @@ public class Transformation { } final var requests = message.getExtensions(DeliveryReceiptRequest.class); return new Transformation( - to, from, type, messageId, stanzaId, extensionListBuilder.build(), requests); + receivedAt, + to, + from, + remote, + type, + messageId, + stanzaId, + 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 new file mode 100644 index 000000000..aa51dfcf5 --- /dev/null +++ b/src/main/java/im/conversations/android/transformer/TransformationFactory.java @@ -0,0 +1,33 @@ +package im.conversations.android.transformer; + +import android.content.Context; +import eu.siacs.conversations.xmpp.Jid; +import im.conversations.android.xmpp.XmppConnection; +import im.conversations.android.xmpp.model.stanza.Message; +import java.time.Instant; + +public class TransformationFactory extends XmppConnection.Delegate { + + public TransformationFactory(Context context, XmppConnection connection) { + super(context, connection); + } + + public Transformation create(final Message message, final String stanzaId) { + return create(message, stanzaId, Instant.now()); + } + + public Transformation create( + final Message message, final String stanzaId, final Instant receivedAt) { + final var boundAddress = connection.getBoundAddress().asBareJid(); + final var from = message.getFrom(); + final var to = message.getTo(); + final Jid remote; + if (from == null || from.asBareJid().equals(boundAddress)) { + remote = to == null ? boundAddress : to; + } else { + remote = from; + } + // TODO parse occupant on group chats + return Transformation.of(message, receivedAt, remote, stanzaId); + } +} diff --git a/src/main/java/im/conversations/android/transformer/Transformer.java b/src/main/java/im/conversations/android/transformer/Transformer.java index 02aeb6fd3..e40f17bfa 100644 --- a/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/src/main/java/im/conversations/android/transformer/Transformer.java @@ -1,13 +1,29 @@ package im.conversations.android.transformer; import android.content.Context; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +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.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.axolotl.Encrypted; +import im.conversations.android.xmpp.model.correction.Replace; import im.conversations.android.xmpp.model.jabber.Body; +import im.conversations.android.xmpp.model.muc.user.MultiUserChat; import im.conversations.android.xmpp.model.oob.OutOfBandData; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Transformer { + private static final Logger LOGGER = LoggerFactory.getLogger(Transformer.class); + private final Context context; private final Account account; @@ -16,23 +32,93 @@ public class Transformer { this.account = account; } + public boolean transform(final Transformation transformation) { + final var database = ConversationsDatabase.getInstance(context); + return database.runInTransaction(() -> transform(database, transformation)); + } + /** * @param transformation * @return returns true if there is something we want to send a delivery receipt for. Basically * anything that created a new message in the database. Notably not something that only * updated a status somewhere */ - public boolean transform(final Transformation transformation) { - final var encrypted = transformation.getExtension(Encrypted.class); - final var bodies = transformation.getExtensions(Body.class); - final var outOfBandData = transformation.getExtensions(OutOfBandData.class); + private boolean transform( + final ConversationsDatabase database, final Transformation transformation) { + final var remote = transformation.remote; + final var messageType = transformation.type; + final var deliveryReceipt = transformation.getExtension(DeliveryReceipt.class); + final Replace lastMessageCorrection = transformation.getExtension(Replace.class); + final var muc = transformation.getExtension(MultiUserChat.class); - // TODO get or create Chat - // TODO create MessageEntity or get existing entity - // TODO for replaced message create a new version; re-target latestVersion - // TODO apply errors, displayed, received etc - // TODO apply reactions + 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 + final boolean versionModification = Objects.nonNull(lastMessageCorrection); + + // TODO get or create Cha + + final ChatIdentifier chat = + database.chatDao() + .getOrCreateChat(account, remote, messageType, Objects.nonNull(muc)); + + if (contents.isEmpty()) { + LOGGER.info("Received message from {} w/o contents", transformation.from); + // TODO apply errors, displayed, received etc + // TODO apply reactions + } else { + if (versionModification) { + // TODO use getOrStub + // TODO check if versionModification has already been applied + + // TODO for replaced message create a new version; re-target latestVersion + + } else { + final var messageIdentifier = + database.messageDao().getOrCreateMessage(chat, transformation); + database.messageDao() + .insertMessageContent(messageIdentifier.latestVersion, contents); + return true; + } + } return true; } + + protected List parseContent(final Transformation transformation) { + final var encrypted = transformation.getExtension(Encrypted.class); + final var encryptedWithPayload = encrypted != null && encrypted.hasPayload(); + final Collection bodies = transformation.getExtensions(Body.class); + final Collection outOfBandData = + transformation.getExtensions(OutOfBandData.class); + final ImmutableList.Builder messageContentBuilder = ImmutableList.builder(); + + // TODO decrypt + + if (bodies.size() == 1 && outOfBandData.size() == 1) { + final String text = Iterables.getOnlyElement(bodies).getContent(); + final String url = Iterables.getOnlyElement(outOfBandData).getURL(); + if (!Strings.isNullOrEmpty(url) && url.equals(text)) { + return ImmutableList.of(MessageContent.file(url)); + } + } + + // TODO verify that body is not fallback + for (final Body body : bodies) { + final String text = body.getContent(); + if (Strings.isNullOrEmpty(text)) { + continue; + } + messageContentBuilder.add(MessageContent.text(text, body.getLang())); + } + for (final OutOfBandData data : outOfBandData) { + final String url = data.getURL(); + if (Strings.isNullOrEmpty(url)) { + continue; + } + messageContentBuilder.add(MessageContent.file(url)); + } + return messageContentBuilder.build(); + } } diff --git a/src/main/java/im/conversations/android/xmpp/Timestamps.java b/src/main/java/im/conversations/android/xmpp/Timestamps.java new file mode 100644 index 000000000..0135901ab --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/Timestamps.java @@ -0,0 +1,44 @@ +package im.conversations.android.xmpp; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public final class Timestamps { + + private Timestamps() { + throw new IllegalStateException("Do not instantiate me"); + } + + public static long parse(final String input) throws ParseException { + if (input == null) { + throw new IllegalArgumentException("timestamp should not be null"); + } + final String timestamp = input.replace("Z", "+0000"); + final SimpleDateFormat simpleDateFormat = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US); + final long milliseconds = getMilliseconds(timestamp); + final String formatted = + timestamp.substring(0, 19) + timestamp.substring(timestamp.length() - 5); + final Date date = simpleDateFormat.parse(formatted); + if (date == null) { + throw new IllegalArgumentException("Date was null"); + } + return date.getTime() + milliseconds; + } + + private static long getMilliseconds(final String timestamp) { + if (timestamp.length() >= 25 && timestamp.charAt(19) == '.') { + final String millis = timestamp.substring(19, timestamp.length() - 5); + try { + double fractions = Double.parseDouble("0" + millis); + return Math.round(1000 * fractions); + } catch (final NumberFormatException e) { + return 0; + } + } else { + return 0; + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/manager/ArchiveManager.java b/src/main/java/im/conversations/android/xmpp/manager/ArchiveManager.java index f7541d058..74f0bbe9b 100644 --- a/src/main/java/im/conversations/android/xmpp/manager/ArchiveManager.java +++ b/src/main/java/im/conversations/android/xmpp/manager/ArchiveManager.java @@ -2,9 +2,10 @@ package im.conversations.android.xmpp.manager; import android.content.Context; import com.google.common.base.Preconditions; -import im.conversations.android.transformer.Transformation; +import im.conversations.android.transformer.TransformationFactory; import im.conversations.android.xmpp.XmppConnection; -import im.conversations.android.xmpp.mam.Result; +import im.conversations.android.xmpp.model.delay.Delay; +import im.conversations.android.xmpp.model.mam.Result; import im.conversations.android.xmpp.model.stanza.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,8 +14,11 @@ public class ArchiveManager extends AbstractManager { private static final Logger LOGGER = LoggerFactory.getLogger(ArchiveManager.class); + private final TransformationFactory transformationFactory; + public ArchiveManager(Context context, XmppConnection connection) { super(context, connection); + this.transformationFactory = new TransformationFactory(context, connection); } public void handle(final Message message) { @@ -24,14 +28,20 @@ public class ArchiveManager extends AbstractManager { final var stanzaId = result.getId(); final var queryId = result.getQueryId(); final var forwarded = result.getForwarded(); - final var forwardedMessage = forwarded == null ? null : forwarded.getMessage(); - if (forwardedMessage == null || queryId == null || stanzaId == null) { + if (forwarded == null || queryId == null || stanzaId == null) { LOGGER.info("Received invalid MAM result from {} ", from); return; } + final var forwardedMessage = forwarded.getMessage(); + final var delay = forwarded.getExtension(Delay.class); + final var receivedAt = delay == null ? null : delay.getStamp(); + if (forwardedMessage == null || receivedAt == null) { + LOGGER.info("MAM result from {} is missing message or receivedAt (delay)", from); + return; + } // TODO get query based on queryId and from - final var transformation = Transformation.of(forwardedMessage, stanzaId); + final var transformation = this.transformationFactory.create(message, stanzaId, receivedAt); // TODO create transformation; add transformation to Query.Transformer } diff --git a/src/main/java/im/conversations/android/xmpp/model/DeliveryReceipt.java b/src/main/java/im/conversations/android/xmpp/model/DeliveryReceipt.java new file mode 100644 index 000000000..00e2b652a --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/DeliveryReceipt.java @@ -0,0 +1,10 @@ +package im.conversations.android.xmpp.model; + +public abstract class DeliveryReceipt extends Extension { + + protected DeliveryReceipt(Class clazz) { + super(clazz); + } + + public abstract String getId(); +} diff --git a/src/main/java/im/conversations/android/xmpp/model/axolotl/Encrypted.java b/src/main/java/im/conversations/android/xmpp/model/axolotl/Encrypted.java index 3319c6bae..d1ac4d73b 100644 --- a/src/main/java/im/conversations/android/xmpp/model/axolotl/Encrypted.java +++ b/src/main/java/im/conversations/android/xmpp/model/axolotl/Encrypted.java @@ -9,4 +9,8 @@ public class Encrypted extends Extension { public Encrypted() { super(Encrypted.class); } + + public boolean hasPayload() { + return hasExtension(Payload.class); + } } diff --git a/src/main/java/im/conversations/android/xmpp/model/delay/Delay.java b/src/main/java/im/conversations/android/xmpp/model/delay/Delay.java new file mode 100644 index 000000000..1f8e98404 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/delay/Delay.java @@ -0,0 +1,29 @@ +package im.conversations.android.xmpp.model.delay; + +import com.google.common.base.Strings; +import eu.siacs.conversations.xml.Namespace; +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.Timestamps; +import im.conversations.android.xmpp.model.Extension; +import java.text.ParseException; +import java.time.Instant; + +@XmlElement(namespace = Namespace.DELAY) +public class Delay extends Extension { + + public Delay() { + super(Delay.class); + } + + public Instant getStamp() { + final var stamp = this.getAttribute("stamp"); + if (Strings.isNullOrEmpty(stamp)) { + return null; + } + try { + return Instant.ofEpochMilli(Timestamps.parse(stamp)); + } catch (final IllegalArgumentException | ParseException e) { + return null; + } + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/jabber/Body.java b/src/main/java/im/conversations/android/xmpp/model/jabber/Body.java index 21e872661..71b49f2e3 100644 --- a/src/main/java/im/conversations/android/xmpp/model/jabber/Body.java +++ b/src/main/java/im/conversations/android/xmpp/model/jabber/Body.java @@ -9,4 +9,8 @@ public class Body extends Extension { public Body() { super(Body.class); } + + public String getLang() { + return this.getAttribute("xml:lang"); + } } diff --git a/src/main/java/im/conversations/android/xmpp/mam/Result.java b/src/main/java/im/conversations/android/xmpp/model/mam/Result.java similarity index 91% rename from src/main/java/im/conversations/android/xmpp/mam/Result.java rename to src/main/java/im/conversations/android/xmpp/model/mam/Result.java index d278b754c..253499756 100644 --- a/src/main/java/im/conversations/android/xmpp/mam/Result.java +++ b/src/main/java/im/conversations/android/xmpp/model/mam/Result.java @@ -1,4 +1,4 @@ -package im.conversations.android.xmpp.mam; +package im.conversations.android.xmpp.model.mam; import im.conversations.android.annotation.XmlElement; import im.conversations.android.xmpp.model.Extension; diff --git a/src/main/java/im/conversations/android/xmpp/mam/package-info.java b/src/main/java/im/conversations/android/xmpp/model/mam/package-info.java similarity index 76% rename from src/main/java/im/conversations/android/xmpp/mam/package-info.java rename to src/main/java/im/conversations/android/xmpp/model/mam/package-info.java index ce14cb1a0..3b3f08d38 100644 --- a/src/main/java/im/conversations/android/xmpp/mam/package-info.java +++ b/src/main/java/im/conversations/android/xmpp/model/mam/package-info.java @@ -1,5 +1,5 @@ @XmlPackage(namespace = Namespace.MESSAGE_ARCHIVE_MANAGEMENT) -package im.conversations.android.xmpp.mam; +package im.conversations.android.xmpp.model.mam; import eu.siacs.conversations.xml.Namespace; import im.conversations.android.annotation.XmlPackage; diff --git a/src/main/java/im/conversations/android/xmpp/model/markers/Received.java b/src/main/java/im/conversations/android/xmpp/model/markers/Received.java index bb350be4d..7007cd176 100644 --- a/src/main/java/im/conversations/android/xmpp/model/markers/Received.java +++ b/src/main/java/im/conversations/android/xmpp/model/markers/Received.java @@ -1,10 +1,10 @@ package im.conversations.android.xmpp.model.markers; import im.conversations.android.annotation.XmlElement; -import im.conversations.android.xmpp.model.Extension; +import im.conversations.android.xmpp.model.DeliveryReceipt; @XmlElement -public class Received extends Extension { +public class Received extends DeliveryReceipt { public Received() { super(Received.class); @@ -13,4 +13,8 @@ public class Received extends Extension { public void setId(String id) { this.setAttribute("id", id); } + + public String getId() { + return this.getAttribute("id"); + } } diff --git a/src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java b/src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java new file mode 100644 index 000000000..f5a16d860 --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java @@ -0,0 +1,12 @@ +package im.conversations.android.xmpp.model.muc.user; + +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement(name = "x") +public class MultiUserChat extends Extension { + + public MultiUserChat() { + super(MultiUserChat.class); + } +} diff --git a/src/main/java/im/conversations/android/xmpp/model/muc/user/package-info.java b/src/main/java/im/conversations/android/xmpp/model/muc/user/package-info.java new file mode 100644 index 000000000..561cd04bb --- /dev/null +++ b/src/main/java/im/conversations/android/xmpp/model/muc/user/package-info.java @@ -0,0 +1,5 @@ +@XmlPackage(namespace = Namespace.MUC_USER) +package im.conversations.android.xmpp.model.muc.user; + +import eu.siacs.conversations.xml.Namespace; +import im.conversations.android.annotation.XmlPackage; diff --git a/src/main/java/im/conversations/android/xmpp/model/receipts/Received.java b/src/main/java/im/conversations/android/xmpp/model/receipts/Received.java index 07d904bc8..71fe922c1 100644 --- a/src/main/java/im/conversations/android/xmpp/model/receipts/Received.java +++ b/src/main/java/im/conversations/android/xmpp/model/receipts/Received.java @@ -1,10 +1,10 @@ package im.conversations.android.xmpp.model.receipts; import im.conversations.android.annotation.XmlElement; -import im.conversations.android.xmpp.model.Extension; +import im.conversations.android.xmpp.model.DeliveryReceipt; @XmlElement -public class Received extends Extension { +public class Received extends DeliveryReceipt { public Received() { super(Received.class); @@ -13,4 +13,8 @@ public class Received extends Extension { public void setId(String id) { this.setAttribute("id", id); } + + public String getId() { + return this.getAttribute("id"); + } } diff --git a/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java index f583af1dd..dc7d658e7 100644 --- a/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java +++ b/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java @@ -1,10 +1,9 @@ package im.conversations.android.xmpp.processor; import android.content.Context; -import im.conversations.android.transformer.Transformation; +import im.conversations.android.transformer.TransformationFactory; import im.conversations.android.transformer.Transformer; import im.conversations.android.xmpp.XmppConnection; -import im.conversations.android.xmpp.mam.Result; import im.conversations.android.xmpp.manager.ArchiveManager; import im.conversations.android.xmpp.manager.CarbonsManager; import im.conversations.android.xmpp.manager.ChatStateManager; @@ -13,6 +12,7 @@ import im.conversations.android.xmpp.manager.ReceiptManager; import im.conversations.android.xmpp.manager.StanzaIdManager; import im.conversations.android.xmpp.model.carbons.Received; import im.conversations.android.xmpp.model.carbons.Sent; +import im.conversations.android.xmpp.model.mam.Result; import im.conversations.android.xmpp.model.pubsub.event.Event; import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.state.ChatStateNotification; @@ -25,6 +25,7 @@ public class MessageProcessor extends XmppConnection.Delegate implements Consume private static final Logger LOGGER = LoggerFactory.getLogger(MessageProcessor.class); private final Level level; + private final TransformationFactory transformationFactory; public MessageProcessor(final Context context, final XmppConnection connection) { this(context, connection, Level.ROOT); @@ -34,6 +35,7 @@ public class MessageProcessor extends XmppConnection.Delegate implements Consume final Context context, final XmppConnection connection, final Level level) { super(context, connection); this.level = level; + this.transformationFactory = new TransformationFactory(context, connection); } @Override @@ -59,10 +61,13 @@ public class MessageProcessor extends XmppConnection.Delegate implements Consume return; } + // LOGGER.info("Message from {} with {}", message.getFrom(), message.getExtensionIds()); + final var from = message.getFrom(); + final var id = message.getId(); final var stanzaId = getManager(StanzaIdManager.class).getStanzaId(message); - final var transformation = Transformation.of(message, stanzaId); + final var transformation = transformationFactory.create(message, stanzaId); final boolean sendReceipts; if (transformation.isAnythingToTransform()) { final var transformer = new Transformer(context, getAccount()); diff --git a/src/test/java/im/conversations/android/xmpp/TimestampTest.java b/src/test/java/im/conversations/android/xmpp/TimestampTest.java new file mode 100644 index 000000000..0698a41d2 --- /dev/null +++ b/src/test/java/im/conversations/android/xmpp/TimestampTest.java @@ -0,0 +1,44 @@ +package im.conversations.android.xmpp; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.XmlElementReader; +import im.conversations.android.xmpp.model.delay.Delay; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class TimestampTest { + + @Test + public void testZuluNoMillis() throws IOException { + final String xml = + ""; + final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8)); + assertThat(element, instanceOf(Delay.class)); + final Delay delay = (Delay) element; + assertEquals(1031699305000L, delay.getStamp().toEpochMilli()); + } + + @Test + public void testZuluWithMillis() throws IOException { + final String xml = + ""; + final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8)); + assertThat(element, instanceOf(Delay.class)); + final Delay delay = (Delay) element; + assertEquals(1031699305023L, delay.getStamp().toEpochMilli()); + } +}