store messages in database

This commit is contained in:
Daniel Gultsch 2023-02-10 14:10:51 +01:00
parent dc371d7017
commit 9b62861a64
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
28 changed files with 752 additions and 71 deletions

View file

@ -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')"
]
}
}

View file

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

View file

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

View file

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

View file

@ -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 dont (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<MessageContent> 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<MessageContentEntity> contentEntities);
}

View file

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

View file

@ -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 =
foreignKeys = {
@ForeignKey(
entity = ChatEntity.class,
parentColumns = {"id"},
childColumns = {"chatId"},
onDelete = ForeignKey.CASCADE),
indices = {@Index(value = "chatId")})
@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;
}
}

View file

@ -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 =
foreignKeys = {
@ForeignKey(
entity = MessageEntity.class,
parentColumns = {"id"},
childColumns = {"messageId"},
childColumns = {"messageEntityId"},
onDelete = ForeignKey.CASCADE),
indices = {@Index(value = "messageId")})
},
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;
}
}

View file

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

View file

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

View file

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

View file

@ -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<Class<? extends Extension>> 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<DeliveryReceiptRequest> 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<Extension> extensions,
final Collection<DeliveryReceiptRequest> 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 extends Extension> E getExtension(final Class<E> 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);
}
}

View file

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

View file

@ -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
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 =
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<MessageContent> parseContent(final Transformation transformation) {
final var encrypted = transformation.getExtension(Encrypted.class);
final var encryptedWithPayload = encrypted != null && encrypted.hasPayload();
final Collection<Body> bodies = transformation.getExtensions(Body.class);
final Collection<OutOfBandData> outOfBandData =
transformation.getExtensions(OutOfBandData.class);
final ImmutableList.Builder<MessageContent> 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();
}
}

View file

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

View file

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

View file

@ -0,0 +1,10 @@
package im.conversations.android.xmpp.model;
public abstract class DeliveryReceipt extends Extension {
protected DeliveryReceipt(Class<? extends Extension> clazz) {
super(clazz);
}
public abstract String getId();
}

View file

@ -9,4 +9,8 @@ public class Encrypted extends Extension {
public Encrypted() {
super(Encrypted.class);
}
public boolean hasPayload() {
return hasExtension(Payload.class);
}
}

View file

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

View file

@ -9,4 +9,8 @@ public class Body extends Extension {
public Body() {
super(Body.class);
}
public String getLang() {
return this.getAttribute("xml:lang");
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 =
"<delay xmlns='urn:xmpp:delay'\n"
+ " from='capulet.com'\n"
+ " stamp='2002-09-10T23:08:25Z'/>";
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 =
"<delay xmlns='urn:xmpp:delay'\n"
+ " from='capulet.com'\n"
+ " stamp='2002-09-10T23:08:25.023Z'/>";
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());
}
}