diff --git a/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/schemas/im.conversations.android.database.ConversationsDatabase/1.json index 8a2506a47..f77abfa77 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": "03075d3509cc0d79cf5e733cff6b71fd", + "identityHash": "b6a7be8218829fd38f51dcd76cb9cccd", "entities": [ { "tableName": "account", @@ -1564,6 +1564,84 @@ } ] }, + { + "tableName": "message_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `messageVersionId` INTEGER NOT NULL, `fromBare` TEXT NOT NULL, `fromResource` TEXT, `type` TEXT NOT NULL, `errorCondition` TEXT, `errorText` TEXT, FOREIGN KEY(`messageVersionId`) REFERENCES `message_version`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageVersionId", + "columnName": "messageVersionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromBare", + "columnName": "fromBare", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromResource", + "columnName": "fromResource", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "errorCondition", + "columnName": "errorCondition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "errorText", + "columnName": "errorText", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_message_state_messageVersionId", + "unique": false, + "columnNames": [ + "messageVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_state_messageVersionId` ON `${TABLE_NAME}` (`messageVersionId`)" + } + ], + "foreignKeys": [ + { + "table": "message_version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageVersionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "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 )", @@ -2150,7 +2228,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, '03075d3509cc0d79cf5e733cff6b71fd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6a7be8218829fd38f51dcd76cb9cccd')" ] } } \ No newline at end of file diff --git a/src/main/java/im/conversations/android/database/ConversationsDatabase.java b/src/main/java/im/conversations/android/database/ConversationsDatabase.java index 04a57bc0b..d2dc8c6d1 100644 --- a/src/main/java/im/conversations/android/database/ConversationsDatabase.java +++ b/src/main/java/im/conversations/android/database/ConversationsDatabase.java @@ -38,6 +38,7 @@ 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.MessageStateEntity; import im.conversations.android.database.entity.MessageVersionEntity; import im.conversations.android.database.entity.NickEntity; import im.conversations.android.database.entity.PresenceEntity; @@ -68,6 +69,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity; DiscoIdentityEntity.class, DiscoItemEntity.class, MessageEntity.class, + MessageStateEntity.class, MessageContentEntity.class, MessageVersionEntity.class, NickEntity.class, diff --git a/src/main/java/im/conversations/android/database/dao/ChatDao.java b/src/main/java/im/conversations/android/database/dao/ChatDao.java index 79c87496a..4eeccd0a3 100644 --- a/src/main/java/im/conversations/android/database/dao/ChatDao.java +++ b/src/main/java/im/conversations/android/database/dao/ChatDao.java @@ -35,6 +35,7 @@ public abstract class ChatDao { if (existing != null) { return existing; } + // TODO do not create entity for 'error' final var entity = new ChatEntity(); entity.accountId = account.id; entity.address = address.toEscapedString(); 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 837c0755a..834197fc8 100644 --- a/src/main/java/im/conversations/android/database/dao/MessageDao.java +++ b/src/main/java/im/conversations/android/database/dao/MessageDao.java @@ -10,19 +10,25 @@ 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.MessageStateEntity; 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.MessageState; import im.conversations.android.database.model.Modification; import im.conversations.android.transformer.Transformation; import java.util.Collection; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Dao public abstract class MessageDao { + private static final Logger LOGGER = LoggerFactory.getLogger(MessageDao.class); + @Query( "UPDATE message SET acknowledged=1 WHERE messageId=:messageId AND toBare=:toBare AND" + " toResource=NULL AND chatId IN (SELECT id FROM chat WHERE accountId=:account)") @@ -115,10 +121,10 @@ public abstract class MessageDao { // 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))") + "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) { @@ -133,4 +139,29 @@ public abstract class MessageDao { @Insert protected abstract void insertMessageContent(Collection contentEntities); + + public void insertMessageState( + ChatIdentifier chatIdentifier, + final String messageId, + final MessageState messageState) { + final Long versionId = getVersionIdForOutgoingMessage(chatIdentifier.id, messageId); + if (versionId == null) { + LOGGER.warn( + "Can not find message {} in chat {} ({})", + messageId, + chatIdentifier.id, + chatIdentifier.address); + return; + } + insert(MessageStateEntity.of(versionId, messageState)); + } + + @Query( + "SELECT message_version.id FROM message_version JOIN message ON" + + " message.id=message_version.messageEntityId WHERE message.chatId=:chatId AND" + + " message_version.messageId=:messageId AND message.outgoing=1") + protected abstract Long getVersionIdForOutgoingMessage(long chatId, final String messageId); + + @Insert + protected abstract void insert(MessageStateEntity messageStateEntity); } diff --git a/src/main/java/im/conversations/android/database/entity/MessageStateEntity.java b/src/main/java/im/conversations/android/database/entity/MessageStateEntity.java new file mode 100644 index 000000000..116a755a7 --- /dev/null +++ b/src/main/java/im/conversations/android/database/entity/MessageStateEntity.java @@ -0,0 +1,51 @@ +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.database.model.MessageState; +import im.conversations.android.database.model.StateType; + +@Entity( + tableName = "message_state", + foreignKeys = + @ForeignKey( + entity = MessageVersionEntity.class, + parentColumns = {"id"}, + childColumns = {"messageVersionId"}, + onDelete = ForeignKey.CASCADE), + indices = {@Index(value = "messageVersionId")}) +public class MessageStateEntity { + + @PrimaryKey(autoGenerate = true) + public Long id; + + @NonNull public Long messageVersionId; + + @NonNull public Jid fromBare; + + @Nullable public String fromResource; + + @NonNull public StateType type; + + public String errorCondition; + + public String errorText; + + public static MessageStateEntity of( + final long messageVersionId, final MessageState messageState) { + final var entity = new MessageStateEntity(); + entity.messageVersionId = messageVersionId; + entity.fromBare = messageState.fromBare; + entity.fromResource = messageState.fromResource; + ; + entity.type = messageState.type; + entity.errorCondition = messageState.errorCondition; + entity.errorText = messageState.errorText; + return entity; + } +} diff --git a/src/main/java/im/conversations/android/database/model/MessageIdentifier.java b/src/main/java/im/conversations/android/database/model/MessageIdentifier.java index d0fddf428..c9b6fb214 100644 --- a/src/main/java/im/conversations/android/database/model/MessageIdentifier.java +++ b/src/main/java/im/conversations/android/database/model/MessageIdentifier.java @@ -1,6 +1,5 @@ package im.conversations.android.database.model; -import com.google.common.base.MoreObjects; import eu.siacs.conversations.xmpp.Jid; public class MessageIdentifier { @@ -9,29 +8,18 @@ public class MessageIdentifier { 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 final Long version; public MessageIdentifier( - long id, String stanzaId, String messageId, Jid fromBare, Long latestVersion) { + long id, String stanzaId, String messageId, Jid fromBare, Long version) { this.id = id; this.stanzaId = stanzaId; this.messageId = messageId; this.fromBare = fromBare; - this.latestVersion = latestVersion; + this.version = version; } public boolean isStub() { - return this.latestVersion == null; + return this.version == null; } } diff --git a/src/main/java/im/conversations/android/database/model/MessageState.java b/src/main/java/im/conversations/android/database/model/MessageState.java new file mode 100644 index 000000000..6fafd58b8 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/MessageState.java @@ -0,0 +1,66 @@ +package im.conversations.android.database.model; + +import com.google.common.base.Preconditions; +import eu.siacs.conversations.xmpp.Jid; +import im.conversations.android.transformer.Transformation; +import im.conversations.android.xmpp.model.error.Condition; +import im.conversations.android.xmpp.model.error.Error; +import im.conversations.android.xmpp.model.error.Text; +import im.conversations.android.xmpp.model.stanza.Message; + +public class MessageState { + + public final Jid fromBare; + + public final String fromResource; + + public final StateType type; + + public final String errorCondition; + + public final String errorText; + + public MessageState( + Jid fromBare, + String fromResource, + StateType type, + String errorCondition, + String errorText) { + this.fromBare = fromBare; + this.fromResource = fromResource; + this.type = type; + this.errorCondition = errorCondition; + this.errorText = errorText; + } + + public static MessageState error(final Transformation transformation) { + Preconditions.checkArgument(transformation.type == Message.Type.ERROR); + final Error error = transformation.getExtension(Error.class); + final Condition condition = error == null ? null : error.getCondition(); + final Text text = error == null ? null : error.getText(); + return new MessageState( + transformation.fromBare(), + transformation.fromResource(), + StateType.ERROR, + condition == null ? null : condition.getName(), + text == null ? null : text.getContent()); + } + + public static MessageState delivered(final Transformation transformation) { + return new MessageState( + transformation.fromBare(), + transformation.fromResource(), + StateType.DELIVERED, + null, + null); + } + + public static MessageState displayed(final Transformation transformation) { + return new MessageState( + transformation.fromBare(), + transformation.fromResource(), + StateType.DISPLAYED, + null, + null); + } +} diff --git a/src/main/java/im/conversations/android/database/model/StateType.java b/src/main/java/im/conversations/android/database/model/StateType.java new file mode 100644 index 000000000..feb20dfd5 --- /dev/null +++ b/src/main/java/im/conversations/android/database/model/StateType.java @@ -0,0 +1,7 @@ +package im.conversations.android.database.model; + +public enum StateType { + DELIVERED, + ERROR, + DISPLAYED +} diff --git a/src/main/java/im/conversations/android/transformer/Transformation.java b/src/main/java/im/conversations/android/transformer/Transformation.java index 23f9491aa..88220e14e 100644 --- a/src/main/java/im/conversations/android/transformer/Transformation.java +++ b/src/main/java/im/conversations/android/transformer/Transformation.java @@ -11,12 +11,14 @@ 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.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.time.Instant; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.List; public class Transformation { @@ -28,7 +30,8 @@ public class Transformation { Encrypted.class, OutOfBandData.class, DeliveryReceipt.class, - MultiUserChat.class); + MultiUserChat.class, + Displayed.class); public final Instant receivedAt; public final Jid to; @@ -113,10 +116,16 @@ public class Transformation { final var type = message.getType(); final var messageId = message.getId(); final ImmutableList.Builder extensionListBuilder = new ImmutableList.Builder<>(); - for (final Class clazz : EXTENSION_FOR_TRANSFORMATION) { - extensionListBuilder.addAll(message.getExtensions(clazz)); + final Collection requests; + if (type == Message.Type.ERROR) { + extensionListBuilder.add(message.getError()); + requests = Collections.emptyList(); + } else { + for (final Class clazz : EXTENSION_FOR_TRANSFORMATION) { + extensionListBuilder.addAll(message.getExtensions(clazz)); + } + requests = message.getExtensions(DeliveryReceiptRequest.class); } - final var requests = message.getExtensions(DeliveryReceiptRequest.class); return new Transformation( receivedAt, to, diff --git a/src/main/java/im/conversations/android/transformer/Transformer.java b/src/main/java/im/conversations/android/transformer/Transformer.java index e40f17bfa..b8c64a768 100644 --- a/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/src/main/java/im/conversations/android/transformer/Transformer.java @@ -8,12 +8,15 @@ 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.MessageState; 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.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.Collection; import java.util.List; import java.util.Objects; @@ -47,26 +50,33 @@ public class Transformer { 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); - - 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 (messageType == Message.Type.ERROR) { + if (transformation.outgoing()) { + LOGGER.info("Ignoring outgoing error to {}", transformation.to); + return false; + } + database.messageDao() + .insertMessageState( + chat, transformation.messageId, MessageState.error(transformation)); + return false; + } + final Replace lastMessageCorrection = 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); + if (contents.isEmpty()) { LOGGER.info("Received message from {} w/o contents", transformation.from); - // TODO apply errors, displayed, received etc + transformMessageState(chat, transformation); // TODO apply reactions } else { if (versionModification) { @@ -78,8 +88,7 @@ public class Transformer { } else { final var messageIdentifier = database.messageDao().getOrCreateMessage(chat, transformation); - database.messageDao() - .insertMessageContent(messageIdentifier.latestVersion, contents); + database.messageDao().insertMessageContent(messageIdentifier.version, contents); return true; } } @@ -121,4 +130,31 @@ public class Transformer { } return messageContentBuilder.build(); } + + private void transformMessageState( + final ChatIdentifier chat, final Transformation transformation) { + final var database = ConversationsDatabase.getInstance(context); + final var displayed = transformation.getExtension(Displayed.class); + if (displayed != null) { + if (transformation.outgoing()) { + LOGGER.info( + "Received outgoing displayed marker for chat with {}", + transformation.remote); + return; + } + database.messageDao() + .insertMessageState( + chat, displayed.getId(), MessageState.displayed(transformation)); + } + final var deliveryReceipt = transformation.getExtension(DeliveryReceipt.class); + if (deliveryReceipt != null) { + if (transformation.outgoing()) { + LOGGER.info("Ignoring outgoing delivery receipt to {}", transformation.to); + return; + } + database.messageDao() + .insertMessageState( + chat, deliveryReceipt.getId(), MessageState.delivered(transformation)); + } + } } diff --git a/src/main/java/im/conversations/android/xmpp/manager/ChatStateManager.java b/src/main/java/im/conversations/android/xmpp/manager/ChatStateManager.java index 7633540f9..d3837b57e 100644 --- a/src/main/java/im/conversations/android/xmpp/manager/ChatStateManager.java +++ b/src/main/java/im/conversations/android/xmpp/manager/ChatStateManager.java @@ -16,6 +16,6 @@ public class ChatStateManager extends AbstractManager { } public void handle(final Jid from, final ChatStateNotification chatState) { - LOGGER.info("Received {} from {}", chatState, from); + // LOGGER.info("Received {} from {}", chatState, from); } } diff --git a/src/main/java/im/conversations/android/xmpp/model/markers/Displayed.java b/src/main/java/im/conversations/android/xmpp/model/markers/Displayed.java index 5b41a9917..be31df35d 100644 --- a/src/main/java/im/conversations/android/xmpp/model/markers/Displayed.java +++ b/src/main/java/im/conversations/android/xmpp/model/markers/Displayed.java @@ -9,4 +9,8 @@ public class Displayed extends Extension { public Displayed() { super(Displayed.class); } + + public String getId() { + return this.getAttribute("id"); + } }