insert message states (displayed, received, error) into DB

This commit is contained in:
Daniel Gultsch 2023-02-11 10:44:07 +01:00
parent 9b62861a64
commit be3a8dc5e1
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
12 changed files with 314 additions and 41 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "03075d3509cc0d79cf5e733cff6b71fd", "identityHash": "b6a7be8218829fd38f51dcd76cb9cccd",
"entities": [ "entities": [
{ {
"tableName": "account", "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", "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 )", "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": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View file

@ -38,6 +38,7 @@ import im.conversations.android.database.entity.DiscoIdentityEntity;
import im.conversations.android.database.entity.DiscoItemEntity; import im.conversations.android.database.entity.DiscoItemEntity;
import im.conversations.android.database.entity.MessageContentEntity; import im.conversations.android.database.entity.MessageContentEntity;
import im.conversations.android.database.entity.MessageEntity; 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.MessageVersionEntity;
import im.conversations.android.database.entity.NickEntity; import im.conversations.android.database.entity.NickEntity;
import im.conversations.android.database.entity.PresenceEntity; import im.conversations.android.database.entity.PresenceEntity;
@ -68,6 +69,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity;
DiscoIdentityEntity.class, DiscoIdentityEntity.class,
DiscoItemEntity.class, DiscoItemEntity.class,
MessageEntity.class, MessageEntity.class,
MessageStateEntity.class,
MessageContentEntity.class, MessageContentEntity.class,
MessageVersionEntity.class, MessageVersionEntity.class,
NickEntity.class, NickEntity.class,

View file

@ -35,6 +35,7 @@ public abstract class ChatDao {
if (existing != null) { if (existing != null) {
return existing; return existing;
} }
// TODO do not create entity for 'error'
final var entity = new ChatEntity(); final var entity = new ChatEntity();
entity.accountId = account.id; entity.accountId = account.id;
entity.address = address.toEscapedString(); entity.address = address.toEscapedString();

View file

@ -10,19 +10,25 @@ import com.google.common.collect.Lists;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.entity.MessageContentEntity; import im.conversations.android.database.entity.MessageContentEntity;
import im.conversations.android.database.entity.MessageEntity; 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.MessageVersionEntity;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.ChatIdentifier; import im.conversations.android.database.model.ChatIdentifier;
import im.conversations.android.database.model.MessageContent; import im.conversations.android.database.model.MessageContent;
import im.conversations.android.database.model.MessageIdentifier; import im.conversations.android.database.model.MessageIdentifier;
import im.conversations.android.database.model.MessageState;
import im.conversations.android.database.model.Modification; import im.conversations.android.database.model.Modification;
import im.conversations.android.transformer.Transformation; import im.conversations.android.transformer.Transformation;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Dao @Dao
public abstract class MessageDao { public abstract class MessageDao {
private static final Logger LOGGER = LoggerFactory.getLogger(MessageDao.class);
@Query( @Query(
"UPDATE message SET acknowledged=1 WHERE messageId=:messageId AND toBare=:toBare 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)") + " toResource=NULL AND chatId IN (SELECT id FROM chat WHERE accountId=:account)")
@ -115,9 +121,9 @@ public abstract class MessageDao {
// we only look up stubs // we only look up stubs
// TODO the from matcher should be in the outer condition // TODO the from matcher should be in the outer condition
@Query( @Query(
"SELECT id,stanzaId,messageId,fromBare,latestVersion FROM message WHERE chatId=:chatId" "SELECT id,stanzaId,messageId,fromBare,latestVersion as version FROM message WHERE"
+ " AND (fromBare=:fromBare OR fromBare=NULL) AND ((stanzaId != NULL AND" + " chatId=:chatId AND (fromBare=:fromBare OR fromBare=NULL) AND ((stanzaId !="
+ " stanzaId=:stanzaId AND (stanzaIdVerified=1 OR latestVersion=NULL)) OR" + " NULL AND stanzaId=:stanzaId AND (stanzaIdVerified=1 OR latestVersion=NULL)) OR"
+ " (stanzaId = NULL AND messageId=:messageId AND latestVersion = NULL))") + " (stanzaId = NULL AND messageId=:messageId AND latestVersion = NULL))")
abstract MessageIdentifier get(long chatId, Jid fromBare, String stanzaId, String messageId); abstract MessageIdentifier get(long chatId, Jid fromBare, String stanzaId, String messageId);
@ -133,4 +139,29 @@ public abstract class MessageDao {
@Insert @Insert
protected abstract void insertMessageContent(Collection<MessageContentEntity> contentEntities); protected abstract void insertMessageContent(Collection<MessageContentEntity> 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);
} }

View file

@ -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;
}
}

View file

@ -1,6 +1,5 @@
package im.conversations.android.database.model; package im.conversations.android.database.model;
import com.google.common.base.MoreObjects;
import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.Jid;
public class MessageIdentifier { public class MessageIdentifier {
@ -9,29 +8,18 @@ public class MessageIdentifier {
public final String stanzaId; public final String stanzaId;
public final String messageId; public final String messageId;
public final Jid fromBare; public final Jid fromBare;
public final Long latestVersion; public final Long version;
@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( 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.id = id;
this.stanzaId = stanzaId; this.stanzaId = stanzaId;
this.messageId = messageId; this.messageId = messageId;
this.fromBare = fromBare; this.fromBare = fromBare;
this.latestVersion = latestVersion; this.version = version;
} }
public boolean isStub() { public boolean isStub() {
return this.latestVersion == null; return this.version == null;
} }
} }

View file

@ -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);
}
}

View file

@ -0,0 +1,7 @@
package im.conversations.android.database.model;
public enum StateType {
DELIVERED,
ERROR,
DISPLAYED
}

View file

@ -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.axolotl.Encrypted;
import im.conversations.android.xmpp.model.jabber.Body; import im.conversations.android.xmpp.model.jabber.Body;
import im.conversations.android.xmpp.model.jabber.Thread; import im.conversations.android.xmpp.model.jabber.Thread;
import im.conversations.android.xmpp.model.markers.Displayed;
import im.conversations.android.xmpp.model.muc.user.MultiUserChat; import im.conversations.android.xmpp.model.muc.user.MultiUserChat;
import im.conversations.android.xmpp.model.oob.OutOfBandData; import im.conversations.android.xmpp.model.oob.OutOfBandData;
import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.stanza.Message;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.List; import java.util.List;
public class Transformation { public class Transformation {
@ -28,7 +30,8 @@ public class Transformation {
Encrypted.class, Encrypted.class,
OutOfBandData.class, OutOfBandData.class,
DeliveryReceipt.class, DeliveryReceipt.class,
MultiUserChat.class); MultiUserChat.class,
Displayed.class);
public final Instant receivedAt; public final Instant receivedAt;
public final Jid to; public final Jid to;
@ -113,10 +116,16 @@ public class Transformation {
final var type = message.getType(); final var type = message.getType();
final var messageId = message.getId(); final var messageId = message.getId();
final ImmutableList.Builder<Extension> extensionListBuilder = new ImmutableList.Builder<>(); final ImmutableList.Builder<Extension> extensionListBuilder = new ImmutableList.Builder<>();
final Collection<DeliveryReceiptRequest> requests;
if (type == Message.Type.ERROR) {
extensionListBuilder.add(message.getError());
requests = Collections.emptyList();
} else {
for (final Class<? extends Extension> clazz : EXTENSION_FOR_TRANSFORMATION) { for (final Class<? extends Extension> clazz : EXTENSION_FOR_TRANSFORMATION) {
extensionListBuilder.addAll(message.getExtensions(clazz)); extensionListBuilder.addAll(message.getExtensions(clazz));
} }
final var requests = message.getExtensions(DeliveryReceiptRequest.class); requests = message.getExtensions(DeliveryReceiptRequest.class);
}
return new Transformation( return new Transformation(
receivedAt, receivedAt,
to, to,

View file

@ -8,12 +8,15 @@ import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.ChatIdentifier; import im.conversations.android.database.model.ChatIdentifier;
import im.conversations.android.database.model.MessageContent; import im.conversations.android.database.model.MessageContent;
import im.conversations.android.database.model.MessageState;
import im.conversations.android.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.DeliveryReceipt;
import im.conversations.android.xmpp.model.axolotl.Encrypted; import im.conversations.android.xmpp.model.axolotl.Encrypted;
import im.conversations.android.xmpp.model.correction.Replace; import im.conversations.android.xmpp.model.correction.Replace;
import im.conversations.android.xmpp.model.jabber.Body; 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.muc.user.MultiUserChat;
import im.conversations.android.xmpp.model.oob.OutOfBandData; import im.conversations.android.xmpp.model.oob.OutOfBandData;
import im.conversations.android.xmpp.model.stanza.Message;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -47,26 +50,33 @@ public class Transformer {
final ConversationsDatabase database, final Transformation transformation) { final ConversationsDatabase database, final Transformation transformation) {
final var remote = transformation.remote; final var remote = transformation.remote;
final var messageType = transformation.type; 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 var muc = transformation.getExtension(MultiUserChat.class);
final List<MessageContent> 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 = final ChatIdentifier chat =
database.chatDao() database.chatDao()
.getOrCreateChat(account, remote, messageType, Objects.nonNull(muc)); .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<MessageContent> 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()) { if (contents.isEmpty()) {
LOGGER.info("Received message from {} w/o contents", transformation.from); LOGGER.info("Received message from {} w/o contents", transformation.from);
// TODO apply errors, displayed, received etc transformMessageState(chat, transformation);
// TODO apply reactions // TODO apply reactions
} else { } else {
if (versionModification) { if (versionModification) {
@ -78,8 +88,7 @@ public class Transformer {
} else { } else {
final var messageIdentifier = final var messageIdentifier =
database.messageDao().getOrCreateMessage(chat, transformation); database.messageDao().getOrCreateMessage(chat, transformation);
database.messageDao() database.messageDao().insertMessageContent(messageIdentifier.version, contents);
.insertMessageContent(messageIdentifier.latestVersion, contents);
return true; return true;
} }
} }
@ -121,4 +130,31 @@ public class Transformer {
} }
return messageContentBuilder.build(); 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));
}
}
} }

View file

@ -16,6 +16,6 @@ public class ChatStateManager extends AbstractManager {
} }
public void handle(final Jid from, final ChatStateNotification chatState) { public void handle(final Jid from, final ChatStateNotification chatState) {
LOGGER.info("Received {} from {}", chatState, from); // LOGGER.info("Received {} from {}", chatState, from);
} }
} }

View file

@ -9,4 +9,8 @@ public class Displayed extends Extension {
public Displayed() { public Displayed() {
super(Displayed.class); super(Displayed.class);
} }
public String getId() {
return this.getAttribute("id");
}
} }