diff --git a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json index d0e218100..e9bbad705 100644 --- a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json +++ b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "b5e8a59bbd86e133c0bc2edd303ad2a0", + "identityHash": "9620a1b63d595091a2b463e89b504eb7", "entities": [ { "tableName": "account", @@ -1009,7 +1009,7 @@ }, { "tableName": "chat", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `type` TEXT, `archived` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `type` TEXT, `archived` INTEGER NOT NULL, `mucState` TEXT, `errorCondition` TEXT, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -1040,6 +1040,18 @@ "columnName": "archived", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "mucState", + "columnName": "mucState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "errorCondition", + "columnName": "errorCondition", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -2007,6 +2019,60 @@ } ] }, + { + "tableName": "muc_status_code", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `chatId` INTEGER NOT NULL, `code` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chat`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_muc_status_code_chatId", + "unique": false, + "columnNames": [ + "chatId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_muc_status_code_chatId` ON `${TABLE_NAME}` (`chatId`)" + } + ], + "foreignKeys": [ + { + "table": "chat", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "tableName": "nick", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `nick` TEXT, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -2528,7 +2594,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, 'b5e8a59bbd86e133c0bc2edd303ad2a0')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9620a1b63d595091a2b463e89b504eb7')" ] } } \ No newline at end of file diff --git a/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java b/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java index c6abb0860..9d8690723 100644 --- a/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java +++ b/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java @@ -46,6 +46,7 @@ import im.conversations.android.database.entity.MessageEntity; import im.conversations.android.database.entity.MessageReactionEntity; import im.conversations.android.database.entity.MessageStateEntity; import im.conversations.android.database.entity.MessageVersionEntity; +import im.conversations.android.database.entity.MucStatusCodeEntity; import im.conversations.android.database.entity.NickEntity; import im.conversations.android.database.entity.PresenceEntity; import im.conversations.android.database.entity.RosterItemEntity; @@ -81,6 +82,7 @@ import im.conversations.android.database.entity.ServiceRecordCacheEntity; MessageStateEntity.class, MessageContentEntity.class, MessageVersionEntity.class, + MucStatusCodeEntity.class, NickEntity.class, PresenceEntity.class, MessageReactionEntity.class, diff --git a/app/src/main/java/im/conversations/android/database/dao/ChatDao.java b/app/src/main/java/im/conversations/android/database/dao/ChatDao.java index b7f6671c0..665ec914d 100644 --- a/app/src/main/java/im/conversations/android/database/dao/ChatDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/ChatDao.java @@ -5,19 +5,28 @@ import androidx.room.Dao; import androidx.room.Insert; import androidx.room.Query; import androidx.room.Transaction; +import com.google.common.util.concurrent.ListenableFuture; import im.conversations.android.database.entity.ChatEntity; +import im.conversations.android.database.entity.MucStatusCodeEntity; 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.database.model.GroupIdentifier; +import im.conversations.android.database.model.MucState; +import im.conversations.android.database.model.MucWithNick; import im.conversations.android.xmpp.model.stanza.Message; import java.util.Arrays; +import java.util.Collection; import java.util.List; import org.jxmpp.jid.Jid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Dao public abstract class ChatDao { + private static final Logger LOGGER = LoggerFactory.getLogger(ChatDao.class); + @Transaction public ChatIdentifier getOrCreateChat( final Account account, @@ -53,9 +62,128 @@ public abstract class ChatDao { + " address=:address") protected abstract ChatIdentifier get(final long accountId, final Jid address); + @Query( + "SELECT id,address,type,archived FROM chat WHERE accountId=:accountId AND" + + " address=:address AND type=:chatType") + public abstract ChatIdentifier get( + final long accountId, final Jid address, final ChatType chatType); + @Insert protected abstract long insert(ChatEntity chatEntity); + @Query("UPDATE chat SET archived=:archived WHERE chat.id=:chatId") + public abstract void setArchived(final long chatId, final boolean archived); + + @Query( + "UPDATE chat SET archived=:archived WHERE chat.accountId=:account AND" + + " chat.address=:address") + protected abstract void setArchived( + final long account, final String address, final boolean archived); + @Query("SELECT id,name FROM `group` ORDER BY name") public abstract LiveData> getGroups(); + + @Transaction + public void syncWithBookmarks(final Account account) { + final var chatsNotInBookmarks = getChatsNotInBookmarks(account.id, ChatType.MUC); + final var bookmarksNotInChat = getBookmarksNotInChats(account.id, ChatType.MUC); + LOGGER.info("chatsNotInBookmark {}", chatsNotInBookmarks); + LOGGER.info("bookmarkNotInChat {}", bookmarksNotInChat); + archive(account.id, chatsNotInBookmarks); + createOrUnarchiveMuc(account.id, bookmarksNotInChat); + } + + private void archive(final long account, final List addresses) { + for (final String address : addresses) { + setArchived(account, address, true); + } + } + + private void createOrUnarchiveMuc(final long account, final List addresses) { + for (final Jid address : addresses) { + createOrUnarchiveMuc(account, address); + } + } + + private void createOrUnarchiveMuc(final long account, final Jid address) { + final var bareJid = address.asBareJid(); + final var existing = get(account, bareJid); + if (existing != null) { + if (existing.archived) { + setArchived(existing.id, false); + } + return; + } + final var entity = new ChatEntity(); + entity.accountId = account; + entity.address = bareJid.toString(); + entity.type = ChatType.MUC; + entity.archived = false; + insert(entity); + } + + @Query( + "SELECT chat.address FROM chat WHERE chat.accountId=:account AND chat.type=:chatType" + + " AND archived=0 EXCEPT SELECT bookmark.address FROM bookmark WHERE" + + " bookmark.accountId=:account AND bookmark.autoJoin=1") + protected abstract List getChatsNotInBookmarks(long account, ChatType chatType); + + @Query( + "SELECT bookmark.address FROM bookmark WHERE bookmark.accountId=accountId AND" + + " bookmark.autoJoin=1 EXCEPT SELECT chat.address FROM chat WHERE" + + " chat.accountId=:account AND chat.type=:chatType AND archived=0") + protected abstract List getBookmarksNotInChats(long account, ChatType chatType); + + @Query( + "SELECT chat.id as chatId,chat.address,bookmark.nick as nickBookmark,nick.nick as" + + " nickAccount FROM chat LEFT JOIN bookmark ON chat.accountId=bookmark.accountId" + + " AND chat.address=bookmark.address JOIN account ON account.id=chat.accountId" + + " LEFT JOIN nick ON nick.accountId=chat.accountId AND" + + " nick.address=account.address WHERE chat.accountId=:account AND" + + " chat.type=:chatType AND chat.archived=0 AND chat.mucState IS NULL") + public abstract ListenableFuture> getMultiUserChats( + final long account, final ChatType chatType); + + @Query("UPDATE chat SET mucState=:mucState, errorCondition=:errorCondition WHERE id=:chatId") + protected abstract void setMucStateInternal( + final long chatId, final MucState mucState, final String errorCondition); + + @Transaction + public void setMucState( + final long chatId, final MucState mucState, final String errorCondition) { + setMucStateInternal(chatId, mucState, errorCondition); + deleteStatusCodes(chatId); + } + + @Transaction + public void setMucState( + final long chatId, final MucState mucState, final Collection statusCodes) { + setMucStateInternal(chatId, mucState, null); + deleteStatusCodes(chatId); + insertStatusCode(MucStatusCodeEntity.of(chatId, statusCodes)); + } + + @Transaction + public void setMucState(final long chatId, final MucState mucState) { + setMucStateInternal(chatId, mucState, null); + deleteStatusCodes(chatId); + } + + @Transaction + public void resetMucStates() { + this.nullMucStates(); + this.deleteStatusCodes(); + } + + @Insert + protected abstract void insertStatusCode(final Collection entities); + + @Query("UPDATE chat SET mucState=null,errorCondition=null") + protected abstract void nullMucStates(); + + @Query("DELETE FROM muc_status_code") + protected abstract void deleteStatusCodes(); + + @Query("DELETE FROM muc_status_code WHERE chatId=:chatId") + protected abstract void deleteStatusCodes(final long chatId); } diff --git a/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java b/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java index 0c91d3859..90ea69f5d 100644 --- a/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java @@ -21,8 +21,6 @@ import im.conversations.android.xmpp.EntityCapabilities; import im.conversations.android.xmpp.EntityCapabilities2; import im.conversations.android.xmpp.model.data.Data; import im.conversations.android.xmpp.model.data.Value; -import im.conversations.android.xmpp.model.disco.info.Feature; -import im.conversations.android.xmpp.model.disco.info.Identity; import im.conversations.android.xmpp.model.disco.info.InfoQuery; import im.conversations.android.xmpp.model.disco.items.Item; import java.util.Collection; @@ -156,13 +154,11 @@ public abstract class DiscoDao { insertDiscoIdentities( Collections2.transform( - infoQuery.getExtensions(Identity.class), - i -> DiscoIdentityEntity.of(discoId, i))); + infoQuery.getIdentities(), i -> DiscoIdentityEntity.of(discoId, i))); insertDiscoFeatures( Collections2.transform( - infoQuery.getExtensions(Feature.class), - f -> DiscoFeatureEntity.of(discoId, f.getVar()))); + infoQuery.getFeatures(), f -> DiscoFeatureEntity.of(discoId, f.getVar()))); for (final Data data : infoQuery.getExtensions(Data.class)) { final var extensionId = insert(DiscoExtensionEntity.of(discoId, data.getFormType())); for (final var field : data.getFields()) { diff --git a/app/src/main/java/im/conversations/android/database/dao/PresenceDao.java b/app/src/main/java/im/conversations/android/database/dao/PresenceDao.java index 55a04f90f..00905751b 100644 --- a/app/src/main/java/im/conversations/android/database/dao/PresenceDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/PresenceDao.java @@ -10,7 +10,7 @@ import im.conversations.android.database.entity.PresenceEntity; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.PresenceShow; import im.conversations.android.database.model.PresenceType; -import im.conversations.android.xmpp.model.muc.user.MultiUserChat; +import im.conversations.android.xmpp.model.muc.user.MucUser; import java.util.Arrays; import org.jxmpp.jid.BareJid; import org.jxmpp.jid.parts.Resourcepart; @@ -41,7 +41,7 @@ public abstract class PresenceDao { @Nullable final String status, @Nullable final String vCardPhoto, @Nullable final String occupantId, - @Nullable final MultiUserChat multiUserChat) { + @Nullable final MucUser mucUser) { if (resource.equals(Resourcepart.EMPTY) && Arrays.asList(PresenceType.ERROR, PresenceType.UNAVAILABLE).contains(type)) { deletePresences(account.id, address); @@ -63,7 +63,7 @@ public abstract class PresenceDao { status, vCardPhoto, occupantId, - multiUserChat); + mucUser); insert(entity); } } diff --git a/app/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java b/app/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java index 758c62a2e..020dc7a7f 100644 --- a/app/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java +++ b/app/src/main/java/im/conversations/android/database/entity/BookmarkEntity.java @@ -5,7 +5,9 @@ import androidx.room.Entity; import androidx.room.ForeignKey; import androidx.room.Index; import androidx.room.PrimaryKey; +import com.google.common.base.Strings; import im.conversations.android.xmpp.model.bookmark.Conference; +import im.conversations.android.xmpp.model.bookmark.Nick; import java.util.Map; import org.jxmpp.jid.Jid; import org.jxmpp.jid.impl.JidCreate; @@ -47,11 +49,13 @@ public class BookmarkEntity { if (address == null) { return null; } + final Nick nick = conference.getNick(); final var entity = new BookmarkEntity(); entity.accountId = accountId; entity.address = address; entity.autoJoin = conference.isAutoJoin(); entity.name = conference.getConferenceName(); + entity.nick = Strings.emptyToNull(nick == null ? null : nick.getContent()); return entity; } } diff --git a/app/src/main/java/im/conversations/android/database/entity/ChatEntity.java b/app/src/main/java/im/conversations/android/database/entity/ChatEntity.java index 188ddf5a8..7ea15b065 100644 --- a/app/src/main/java/im/conversations/android/database/entity/ChatEntity.java +++ b/app/src/main/java/im/conversations/android/database/entity/ChatEntity.java @@ -1,11 +1,13 @@ 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 im.conversations.android.database.model.ChatType; +import im.conversations.android.database.model.MucState; @Entity( tableName = "chat", @@ -32,4 +34,7 @@ public class ChatEntity { public ChatType type; public boolean archived; + + @Nullable public MucState mucState; + @Nullable public String errorCondition; } diff --git a/app/src/main/java/im/conversations/android/database/entity/MucStatusCodeEntity.java b/app/src/main/java/im/conversations/android/database/entity/MucStatusCodeEntity.java new file mode 100644 index 000000000..88c46c9ab --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/entity/MucStatusCodeEntity.java @@ -0,0 +1,40 @@ +package im.conversations.android.database.entity; + +import androidx.annotation.NonNull; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Index; +import androidx.room.PrimaryKey; +import com.google.common.collect.Collections2; +import java.util.Collection; + +@Entity( + tableName = "muc_status_code", + foreignKeys = { + @ForeignKey( + entity = ChatEntity.class, + parentColumns = {"id"}, + childColumns = {"chatId"}, + onDelete = ForeignKey.CASCADE) + }, + indices = {@Index(value = "chatId")}) +public class MucStatusCodeEntity { + @PrimaryKey(autoGenerate = true) + public Long id; + + @NonNull public Long chatId; + + @NonNull public Integer code; + + public static Collection of( + final long chatId, final Collection codes) { + return Collections2.transform(codes, c -> of(chatId, c)); + } + + private static MucStatusCodeEntity of(final long chatId, final int code) { + final var entity = new MucStatusCodeEntity(); + entity.chatId = chatId; + entity.code = code; + return entity; + } +} diff --git a/app/src/main/java/im/conversations/android/database/entity/PresenceEntity.java b/app/src/main/java/im/conversations/android/database/entity/PresenceEntity.java index 79480f95f..b0b844075 100644 --- a/app/src/main/java/im/conversations/android/database/entity/PresenceEntity.java +++ b/app/src/main/java/im/conversations/android/database/entity/PresenceEntity.java @@ -10,7 +10,7 @@ import im.conversations.android.database.model.PresenceShow; import im.conversations.android.database.model.PresenceType; import im.conversations.android.xmpp.model.muc.Affiliation; import im.conversations.android.xmpp.model.muc.Role; -import im.conversations.android.xmpp.model.muc.user.MultiUserChat; +import im.conversations.android.xmpp.model.muc.user.MucUser; import org.jxmpp.jid.BareJid; import org.jxmpp.jid.Jid; import org.jxmpp.jid.parts.Resourcepart; @@ -76,8 +76,8 @@ public class PresenceEntity { final String status, final String vCardPhoto, final String occupantId, - final MultiUserChat multiUserChat) { - final var mucItem = multiUserChat == null ? null : multiUserChat.getItem(); + final MucUser mucUser) { + final var mucUserItem = mucUser == null ? null : mucUser.getItem(); final var entity = new PresenceEntity(); entity.accountId = account; entity.address = address; @@ -86,12 +86,12 @@ public class PresenceEntity { entity.show = show; entity.status = status; entity.vCardPhoto = vCardPhoto; - if (mucItem != null) { + if (mucUserItem != null) { entity.occupantId = occupantId; - entity.mucUserAffiliation = mucItem.getAffiliation(); - entity.mucUserRole = mucItem.getRole(); - entity.mucUserJid = mucItem.getJid(); - entity.mucUserSelf = multiUserChat.getStatus().contains(110); + entity.mucUserAffiliation = mucUserItem.getAffiliation(); + entity.mucUserRole = mucUserItem.getRole(); + entity.mucUserJid = mucUserItem.getJid(); + entity.mucUserSelf = mucUser.getStatus().contains(110); } return entity; } diff --git a/app/src/main/java/im/conversations/android/database/model/Account.java b/app/src/main/java/im/conversations/android/database/model/Account.java index 11e7ddf56..306aa38d7 100644 --- a/app/src/main/java/im/conversations/android/database/model/Account.java +++ b/app/src/main/java/im/conversations/android/database/model/Account.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.hash.Hashing; +import com.google.common.io.BaseEncoding; import com.google.common.io.ByteSource; import com.google.common.primitives.Ints; import im.conversations.android.IDs; @@ -11,6 +12,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.UUID; import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.parts.Resourcepart; public class Account extends AccountIdentifier { @@ -59,4 +61,23 @@ public class Account extends AccountIdentifier { throw new RuntimeException(e); } } + + public Resourcepart fallbackNick() { + final var localPart = address.getLocalpartOrNull(); + if (localPart != null) { + final var resourceFromLocalPart = Resourcepart.fromOrNull(localPart.toString()); + if (resourceFromLocalPart != null) { + return resourceFromLocalPart; + } + } + try { + return Resourcepart.fromOrThrowUnchecked( + BaseEncoding.base32Hex() + .lowerCase() + .omitPadding() + .encode(ByteSource.wrap(randomSeed).slice(0, 6).read())); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/app/src/main/java/im/conversations/android/database/model/MucState.java b/app/src/main/java/im/conversations/android/database/model/MucState.java new file mode 100644 index 000000000..7342dc470 --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/MucState.java @@ -0,0 +1,10 @@ +package im.conversations.android.database.model; + +public enum MucState { + JOINING, + AVAILABLE, + ERROR_PRESENCE, + ERROR_IQ, + UNAVAILABLE, + NOT_A_MUC +} diff --git a/app/src/main/java/im/conversations/android/database/model/MucWithNick.java b/app/src/main/java/im/conversations/android/database/model/MucWithNick.java new file mode 100644 index 000000000..cb85f4ebf --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/MucWithNick.java @@ -0,0 +1,39 @@ +package im.conversations.android.database.model; + +import com.google.common.base.MoreObjects; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.parts.Resourcepart; + +public class MucWithNick { + public final long chatId; + + public final BareJid address; + private final String nickBookmark; + private final String nickAccount; + + public MucWithNick( + final long chatId, + final BareJid address, + final String nickBookmark, + final String nickAccount) { + this.chatId = chatId; + this.address = address; + this.nickBookmark = nickBookmark; + this.nickAccount = nickAccount; + } + + public Resourcepart nick() { + final var bookmark = nickBookmark == null ? null : Resourcepart.fromOrNull(nickBookmark); + final var account = nickAccount == null ? null : Resourcepart.fromOrNull(nickAccount); + return bookmark != null ? bookmark : account; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("address", address) + .add("nickBookmark", nickBookmark) + .add("nickAccount", nickAccount) + .toString(); + } +} diff --git a/app/src/main/java/im/conversations/android/transformer/MessageTransformation.java b/app/src/main/java/im/conversations/android/transformer/MessageTransformation.java index 36cabc084..ce800315f 100644 --- a/app/src/main/java/im/conversations/android/transformer/MessageTransformation.java +++ b/app/src/main/java/im/conversations/android/transformer/MessageTransformation.java @@ -14,7 +14,7 @@ import im.conversations.android.xmpp.model.error.Error; 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.muc.user.MucUser; import im.conversations.android.xmpp.model.oob.OutOfBandData; import im.conversations.android.xmpp.model.reactions.Reactions; import im.conversations.android.xmpp.model.reply.Reply; @@ -37,7 +37,7 @@ public class MessageTransformation extends Transformation { Encrypted.class, OutOfBandData.class, DeliveryReceipt.class, - MultiUserChat.class, + MucUser.class, Displayed.class, Replace.class, Reactions.class, diff --git a/app/src/main/java/im/conversations/android/transformer/Transformer.java b/app/src/main/java/im/conversations/android/transformer/Transformer.java index 7e6e959ba..b7dfc2a9e 100644 --- a/app/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/app/src/main/java/im/conversations/android/transformer/Transformer.java @@ -14,7 +14,7 @@ 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.markers.Displayed; -import im.conversations.android.xmpp.model.muc.user.MultiUserChat; +import im.conversations.android.xmpp.model.muc.user.MucUser; import im.conversations.android.xmpp.model.reactions.Reactions; import im.conversations.android.xmpp.model.reply.Reply; import im.conversations.android.xmpp.model.retract.Retract; @@ -72,7 +72,7 @@ public class Transformer { final ConversationsDatabase database, final MessageTransformation transformation) { final var remote = transformation.remote; final var messageType = transformation.type; - final var muc = transformation.getExtension(MultiUserChat.class); + final var muc = transformation.getExtension(MucUser.class); final ChatIdentifier chat = database.chatDao() diff --git a/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities.java b/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities.java index ceb81a1fc..b282c0791 100644 --- a/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities.java +++ b/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities.java @@ -37,7 +37,7 @@ public final class EntityCapabilities { blankNull(a.getIdentityName()), blankNull(b.getIdentityName())) .result()) - .sortedCopy(info.getExtensions(Identity.class)); + .sortedCopy(info.getIdentities()); for (final Identity id : orderedIdentities) { s.append(blankNull(id.getCategory())) @@ -52,9 +52,7 @@ public final class EntityCapabilities { final List features = Ordering.natural() - .sortedCopy( - Collections2.transform( - info.getExtensions(Feature.class), Feature::getVar)); + .sortedCopy(Collections2.transform(info.getFeatures(), Feature::getVar)); for (final String feature : features) { s.append(clean(feature)).append("<"); } diff --git a/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities2.java b/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities2.java index 8541e48a3..a7f6948c8 100644 --- a/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities2.java +++ b/app/src/main/java/im/conversations/android/xmpp/EntityCapabilities2.java @@ -62,8 +62,8 @@ public class EntityCapabilities2 { } private static String algorithm(final InfoQuery infoQuery) { - return features(infoQuery.getExtensions(Feature.class)) - + identities(infoQuery.getExtensions(Identity.class)) + return features(infoQuery.getFeatures()) + + identities(infoQuery.getIdentities()) + extensions(infoQuery.getExtensions(Data.class)); } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java index c49dec249..912feeae7 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java @@ -11,6 +11,7 @@ import im.conversations.android.xml.Namespace; import im.conversations.android.xmpp.NodeConfiguration; import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.model.bookmark.Conference; +import im.conversations.android.xmpp.model.bookmark.Nick; import im.conversations.android.xmpp.model.pubsub.Items; import im.conversations.android.xmpp.model.pubsub.event.Retract; import java.util.Collection; @@ -36,7 +37,7 @@ public class BookmarkManager extends AbstractManager { new FutureCallback<>() { @Override public void onSuccess(final Map bookmarks) { - getDatabase().bookmarkDao().setItems(getAccount(), bookmarks); + setBookmarks(bookmarks); } @Override @@ -47,6 +48,17 @@ public class BookmarkManager extends AbstractManager { MoreExecutors.directExecutor()); } + private void setBookmarks(final Map bookmarks) { + final var database = getDatabase(); + final var account = getAccount(); + database.runInTransaction( + () -> { + database.bookmarkDao().setItems(account, bookmarks); + database.chatDao().syncWithBookmarks(account); + }); + getManager(MultiUserChatManager.class).joinMultiUserChats(); + } + private void updateItems(final Map items) { getDatabase().bookmarkDao().updateItems(getAccount(), items); } @@ -74,9 +86,18 @@ public class BookmarkManager extends AbstractManager { } } - public ListenableFuture publishBookmark(final Jid address) { + public ListenableFuture publishBookmark(final Jid address, final boolean autoJoin) { + return publishBookmark(address, autoJoin, null); + } + + public ListenableFuture publishBookmark( + final Jid address, final boolean autoJoin, final String nick) { final var itemId = address.toString(); final var conference = new Conference(); + conference.setAutoJoin(autoJoin); + if (nick != null) { + conference.addExtension(new Nick()).setContent(nick); + } return Futures.transform( getManager(PepManager.class) .publish(conference, itemId, NodeConfiguration.WHITELIST_MAX_ITEMS), diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java index 58d1ca293..ad33c929c 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java @@ -1,12 +1,30 @@ package im.conversations.android.xmpp.manager; import android.content.Context; +import androidx.annotation.NonNull; +import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import im.conversations.android.database.model.ChatIdentifier; +import im.conversations.android.database.model.ChatType; +import im.conversations.android.database.model.MucState; +import im.conversations.android.database.model.MucWithNick; +import im.conversations.android.xml.Namespace; +import im.conversations.android.xmpp.Entity; +import im.conversations.android.xmpp.IqErrorException; import im.conversations.android.xmpp.XmppConnection; -import im.conversations.android.xmpp.model.muc.user.MultiUserChat; +import im.conversations.android.xmpp.model.disco.info.InfoQuery; +import im.conversations.android.xmpp.model.error.Condition; +import im.conversations.android.xmpp.model.error.Error; +import im.conversations.android.xmpp.model.muc.History; +import im.conversations.android.xmpp.model.muc.MultiUserChat; +import im.conversations.android.xmpp.model.muc.user.MucUser; import im.conversations.android.xmpp.model.stanza.Presence; -import org.jxmpp.jid.BareJid; +import java.util.List; +import org.jxmpp.jid.Jid; import org.jxmpp.jid.impl.JidCreate; -import org.jxmpp.jid.parts.Resourcepart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,11 +36,157 @@ public class MultiUserChatManager extends AbstractManager { super(context, connection); } - public void enter(final BareJid room) { + public ListenableFuture joinMultiUserChats() { + LOGGER.info("joining multi user chats. start"); + return Futures.transform( + getDatabase().chatDao().getMultiUserChats(getAccount().id, ChatType.MUC), + this::joinMultiUserChats, + MoreExecutors.directExecutor()); + } + + private int joinMultiUserChats(final List chats) { + LOGGER.info("joining {} chats", chats.size()); + for (final MucWithNick chat : chats) { + this.enterExisting(chat); + } + return chats.size(); + } + + public void enterExisting(final MucWithNick mucWithNick) { + getDatabase().chatDao().setMucState(mucWithNick.chatId, MucState.JOINING); + final var discoInfoFuture = + getManager(DiscoManager.class).info(Entity.discoItem(mucWithNick.address)); + Futures.addCallback( + discoInfoFuture, + new ExistingMucJoiner(mucWithNick), + MoreExecutors.directExecutor()); + } + + private void enterExisting(final MucWithNick mucWithNick, final InfoQuery infoQuery) { + if (infoQuery.hasFeature(Namespace.MUC)) { + sendJoinPresence(mucWithNick); + } else { + getDatabase().chatDao().setMucState(mucWithNick.chatId, MucState.NOT_A_MUC); + } + } + + private void sendJoinPresence(final MucWithNick mucWithNick) { + final var nick = mucWithNick.nick(); + final Jid to; + if (nick != null) { + to = JidCreate.fullFrom(mucWithNick.address, nick); + } else { + to = JidCreate.fullFrom(mucWithNick.address, getAccount().fallbackNick()); + } final var presence = new Presence(); - presence.setTo(JidCreate.fullFrom(room, Resourcepart.fromOrThrowUnchecked("c3-test-user"))); - presence.addExtension(new MultiUserChat()); + presence.setTo(to); + final var muc = presence.addExtension(new MultiUserChat()); + final var history = muc.addExtension(new History()); + history.setMaxChars(0); LOGGER.info("sending {} ", presence); connection.sendPresencePacket(presence); } + + public void handleSelfPresenceAvailable(final Presence presencePacket) { + final MucUser mucUser = presencePacket.getExtension(MucUser.class); + Preconditions.checkArgument( + mucUser.getStatus().contains(MucUser.STATUS_CODE_SELF_PRESENCE)); + // TODO flag chat as joined + LOGGER.info("Received self presence for {}", presencePacket.getFrom()); + final var database = getDatabase(); + database.runInTransaction( + () -> { + final ChatIdentifier chatIdentifier = + database.chatDao() + .get( + getAccount().id, + presencePacket.getFrom().asBareJid(), + ChatType.MUC); + if (chatIdentifier == null || chatIdentifier.archived) { + LOGGER.info( + "Available presence received for archived or non existent chat"); + return; + } + // TODO set status codes + database.chatDao() + .setMucState( + chatIdentifier.id, MucState.AVAILABLE, mucUser.getStatus()); + }); + } + + public void handleSelfPresenceUnavailable(final Presence presencePacket) { + final MucUser mucUser = presencePacket.getExtension(MucUser.class); + Preconditions.checkArgument( + mucUser.getStatus().contains(MucUser.STATUS_CODE_SELF_PRESENCE)); + final var database = getDatabase(); + database.runInTransaction( + () -> { + final ChatIdentifier chatIdentifier = + database.chatDao() + .get( + getAccount().id, + presencePacket.getFrom().asBareJid(), + ChatType.MUC); + if (chatIdentifier == null) { + LOGGER.error("Unavailable presence received for non existent chat"); + } else if (chatIdentifier.archived) { + database.chatDao().setMucState(chatIdentifier.id, null); + } else { + // TODO set status codes + database.chatDao().setMucState(chatIdentifier.id, MucState.UNAVAILABLE); + } + }); + } + + public void handleErrorPresence(final Presence presencePacket) { + LOGGER.info("Received error presence from {}", presencePacket.getFrom()); + final var database = getDatabase(); + database.runInTransaction( + () -> { + final ChatIdentifier chatIdentifier = + database.chatDao() + .get( + getAccount().id, + presencePacket.getFrom().asBareJid(), + ChatType.MUC); + if (chatIdentifier == null) { + // this is fine. error is simply not for a MUC + return; + } + final Error error = presencePacket.getError(); + final Condition condition = error == null ? null : error.getCondition(); + final String errorCondition = condition == null ? null : condition.getName(); + database.chatDao() + .setMucState( + chatIdentifier.id, MucState.ERROR_PRESENCE, errorCondition); + }); + } + + private class ExistingMucJoiner implements FutureCallback { + + private final MucWithNick chat; + + private ExistingMucJoiner(final MucWithNick chat) { + this.chat = chat; + } + + @Override + public void onSuccess(final InfoQuery result) { + enterExisting(chat, result); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + final String errorCondition; + if (throwable instanceof IqErrorException) { + final var iqErrorException = (IqErrorException) throwable; + final Error error = iqErrorException.getError(); + final Condition condition = error == null ? null : error.getCondition(); + errorCondition = condition == null ? null : condition.getName(); + } else { + errorCondition = null; + } + getDatabase().chatDao().setMucState(chat.chatId, MucState.ERROR_IQ, errorCondition); + } + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/NickManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/NickManager.java index 74c97fe19..98aeb8437 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/NickManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/NickManager.java @@ -2,6 +2,8 @@ package im.conversations.android.xmpp.manager; import android.content.Context; import com.google.common.base.Strings; +import com.google.common.util.concurrent.ListenableFuture; +import im.conversations.android.xmpp.NodeConfiguration; import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.model.nick.Nick; import im.conversations.android.xmpp.model.pubsub.Items; @@ -25,4 +27,10 @@ public class NickManager extends AbstractManager { } getDatabase().nickDao().set(getAccount(), from.asBareJid(), nick); } + + public ListenableFuture publishNick(final String name) { + final Nick nick = new Nick(); + nick.setContent(name); + return getManager(PepManager.class).publishSingleton(nick, NodeConfiguration.PRESENCE); + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/PepManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/PepManager.java index 1b5618f23..ca05d23b9 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/PepManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/PepManager.java @@ -34,6 +34,11 @@ public class PepManager extends AbstractManager { return pubSubManager().publishSingleton(pepService(), item, node, nodeConfiguration); } + public ListenableFuture publishSingleton( + final Extension item, final NodeConfiguration nodeConfiguration) { + return pubSubManager().publishSingleton(pepService(), item, nodeConfiguration); + } + public ListenableFuture retract(final String itemId, final String node) { return pubSubManager().retract(pepService(), itemId, node); } diff --git a/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java b/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java index 97caf1371..e2248428b 100644 --- a/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java +++ b/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java @@ -17,4 +17,12 @@ public class Conference extends Extension { public String getConferenceName() { return this.getAttribute("name"); } + + public void setAutoJoin(boolean autoJoin) { + setAttribute("autojoin", autoJoin); + } + + public Nick getNick() { + return this.getExtension(Nick.class); + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Nick.java b/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Nick.java new file mode 100644 index 000000000..ee5efa386 --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/bookmark/Nick.java @@ -0,0 +1,12 @@ +package im.conversations.android.xmpp.model.bookmark; + +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement +public class Nick extends Extension { + + public Nick() { + super(Nick.class); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java b/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java index 8b8935da8..5c75f7211 100644 --- a/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java +++ b/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java @@ -1,7 +1,9 @@ package im.conversations.android.xmpp.model.disco.info; +import com.google.common.collect.Iterables; import im.conversations.android.annotation.XmlElement; import im.conversations.android.xmpp.model.Extension; +import java.util.Collection; @XmlElement(name = "query") public class InfoQuery extends Extension { @@ -17,4 +19,16 @@ public class InfoQuery extends Extension { public String getNode() { return this.getAttribute("node"); } + + public Collection getFeatures() { + return this.getExtensions(Feature.class); + } + + public boolean hasFeature(final String feature) { + return Iterables.any(getFeatures(), f -> feature.equals(f.getVar())); + } + + public Collection getIdentities() { + return this.getExtensions(Identity.class); + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/model/muc/History.java b/app/src/main/java/im/conversations/android/xmpp/model/muc/History.java new file mode 100644 index 000000000..e09210e60 --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/muc/History.java @@ -0,0 +1,20 @@ +package im.conversations.android.xmpp.model.muc; + +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement +public class History extends Extension { + + public History() { + super(History.class); + } + + public void setMaxChars(final int maxChars) { + this.setAttribute("maxchars", maxChars); + } + + public void setMaxStanzas(final int maxStanzas) { + this.setAttribute("maxstanzas", maxStanzas); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/muc/MultiUserChat.java b/app/src/main/java/im/conversations/android/xmpp/model/muc/MultiUserChat.java new file mode 100644 index 000000000..33da7b9af --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/muc/MultiUserChat.java @@ -0,0 +1,12 @@ +package im.conversations.android.xmpp.model.muc; + +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/app/src/main/java/im/conversations/android/xmpp/model/muc/package-info.java b/app/src/main/java/im/conversations/android/xmpp/model/muc/package-info.java new file mode 100644 index 000000000..766da9c1c --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/muc/package-info.java @@ -0,0 +1,5 @@ +@XmlPackage(namespace = Namespace.MUC) +package im.conversations.android.xmpp.model.muc; + +import im.conversations.android.annotation.XmlPackage; +import im.conversations.android.xml.Namespace; diff --git a/app/src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java b/app/src/main/java/im/conversations/android/xmpp/model/muc/user/MucUser.java similarity index 79% rename from app/src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java rename to app/src/main/java/im/conversations/android/xmpp/model/muc/user/MucUser.java index 061e1deae..5496c3ef2 100644 --- a/app/src/main/java/im/conversations/android/xmpp/model/muc/user/MultiUserChat.java +++ b/app/src/main/java/im/conversations/android/xmpp/model/muc/user/MucUser.java @@ -7,10 +7,12 @@ import java.util.Collection; import java.util.Objects; @XmlElement(name = "x") -public class MultiUserChat extends Extension { +public class MucUser extends Extension { - public MultiUserChat() { - super(MultiUserChat.class); + public static final int STATUS_CODE_SELF_PRESENCE = 110; + + public MucUser() { + super(MucUser.class); } public Item getItem() { diff --git a/app/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java b/app/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java index 800f7be33..3a1a8f0c2 100644 --- a/app/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java +++ b/app/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java @@ -12,9 +12,13 @@ import im.conversations.android.xmpp.manager.PresenceManager; import im.conversations.android.xmpp.manager.RosterManager; import java.util.function.Consumer; import org.jxmpp.jid.Jid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class BindProcessor extends XmppConnection.Delegate implements Consumer { + private static final Logger LOGGER = LoggerFactory.getLogger(BindProcessor.class); + public BindProcessor(final Context context, final XmppConnection connection) { super(context, connection); } @@ -24,7 +28,12 @@ public class BindProcessor extends XmppConnection.Delegate implements Consumer { + database.chatDao().resetMucStates(); + database.presenceDao().deletePresences(account.id); + }); getManager(RosterManager.class).fetch(); diff --git a/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java b/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java index 1f48a661a..29e3527a9 100644 --- a/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java +++ b/app/src/main/java/im/conversations/android/xmpp/processor/PresenceProcessor.java @@ -7,7 +7,8 @@ import im.conversations.android.xml.Namespace; import im.conversations.android.xmpp.Entity; import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.manager.DiscoManager; -import im.conversations.android.xmpp.model.muc.user.MultiUserChat; +import im.conversations.android.xmpp.manager.MultiUserChatManager; +import im.conversations.android.xmpp.model.muc.user.MucUser; import im.conversations.android.xmpp.model.occupant.OccupantId; import im.conversations.android.xmpp.model.stanza.Presence; import im.conversations.android.xmpp.model.vcard.update.VCardUpdate; @@ -45,7 +46,7 @@ public class PresenceProcessor extends XmppConnection.Delegate implements Consum final var vCardUpdate = presencePacket.getExtension(VCardUpdate.class); final var vCardPhoto = vCardUpdate == null ? null : vCardUpdate.getHash(); - final var muc = presencePacket.getExtension(MultiUserChat.class); + final var muc = presencePacket.getExtension(MucUser.class); final String occupantId; if (muc != null && presencePacket.hasExtension(OccupantId.class)) { @@ -72,6 +73,18 @@ public class PresenceProcessor extends XmppConnection.Delegate implements Consum occupantId, muc); + final var mucManager = getManager(MultiUserChatManager.class); + if (muc != null && muc.getStatus().contains(MucUser.STATUS_CODE_SELF_PRESENCE)) { + if (type == null) { + mucManager.handleSelfPresenceAvailable(presencePacket); + } else if (type == PresenceType.UNAVAILABLE) { + mucManager.handleSelfPresenceUnavailable(presencePacket); + } + } + if (type == PresenceType.ERROR) { + mucManager.handleErrorPresence(presencePacket); + } + // TODO do this only for contacts? fetchCapabilities(presencePacket); }