move most MUC handling into Manager

Daniel Gultsch created

Change summary

src/main/java/de/gultsch/common/FutureMerger.java                                |  42 
src/main/java/de/gultsch/common/IntMap.java                                      | 100 
src/main/java/eu/siacs/conversations/entities/Account.java                       |   4 
src/main/java/eu/siacs/conversations/entities/Bookmark.java                      |  32 
src/main/java/eu/siacs/conversations/entities/MucOptions.java                    | 174 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java                  |  77 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java             |  67 
src/main/java/eu/siacs/conversations/parser/AbstractParser.java                  |   6 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                   |  94 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                  |  28 
src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java       |   9 
src/main/java/eu/siacs/conversations/services/MessageArchiveService.java         |  13 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java         | 928 
src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java            |   5 
src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java           | 212 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java                |   5 
src/main/java/eu/siacs/conversations/ui/CreatePublicChannelDialog.java           |   8 
src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java                 | 173 
src/main/java/eu/siacs/conversations/ui/adapter/UserPreviewAdapter.java          |   5 
src/main/java/eu/siacs/conversations/ui/util/MucConfiguration.java               |  18 
src/main/java/eu/siacs/conversations/ui/util/MucDetailsContextMenuHelper.java    |  70 
src/main/java/eu/siacs/conversations/utils/Resolver.java                         |   9 
src/main/java/eu/siacs/conversations/xml/Element.java                            |  11 
src/main/java/eu/siacs/conversations/xml/Namespace.java                          |   6 
src/main/java/eu/siacs/conversations/xmpp/IqErrorException.java                  |   8 
src/main/java/eu/siacs/conversations/xmpp/Managers.java                          |   2 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                    |  32 
src/main/java/eu/siacs/conversations/xmpp/manager/AbstractBookmarkManager.java   |   2 
src/main/java/eu/siacs/conversations/xmpp/manager/BookmarkManager.java           | 246 
src/main/java/eu/siacs/conversations/xmpp/manager/DiscoManager.java              |   2 
src/main/java/eu/siacs/conversations/xmpp/manager/LegacyBookmarkManager.java     |   2 
src/main/java/eu/siacs/conversations/xmpp/manager/MultiUserChatManager.java      | 974 
src/main/java/eu/siacs/conversations/xmpp/manager/NativeBookmarkManager.java     | 172 
src/main/java/eu/siacs/conversations/xmpp/manager/PingManager.java               |   8 
src/main/java/eu/siacs/conversations/xmpp/manager/PresenceManager.java           |   9 
src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java             |   6 
src/main/java/eu/siacs/conversations/xmpp/manager/VCardManager.java              |   3 
src/main/java/im/conversations/android/xmpp/model/conference/DirectInvite.java   |  21 
src/main/java/im/conversations/android/xmpp/model/conference/package-info.java   |   5 
src/main/java/im/conversations/android/xmpp/model/data/Data.java                 |  29 
src/main/java/im/conversations/android/xmpp/model/data/Field.java                |   1 
src/main/java/im/conversations/android/xmpp/model/hints/NoCopy.java              |  11 
src/main/java/im/conversations/android/xmpp/model/hints/NoStore.java             |  12 
src/main/java/im/conversations/android/xmpp/model/jabber/Subject.java            |   5 
src/main/java/im/conversations/android/xmpp/model/muc/History.java               |   5 
src/main/java/im/conversations/android/xmpp/model/muc/Item.java                  |  54 
src/main/java/im/conversations/android/xmpp/model/muc/Password.java              |  17 
src/main/java/im/conversations/android/xmpp/model/muc/admin/Item.java            |  30 
src/main/java/im/conversations/android/xmpp/model/muc/admin/MucAdmin.java        |  17 
src/main/java/im/conversations/android/xmpp/model/muc/admin/package-info.java    |   5 
src/main/java/im/conversations/android/xmpp/model/muc/owner/Destroy.java         |  12 
src/main/java/im/conversations/android/xmpp/model/muc/owner/MucOwner.java        |  16 
src/main/java/im/conversations/android/xmpp/model/muc/owner/package-info.java    |   5 
src/main/java/im/conversations/android/xmpp/model/muc/user/Invite.java           |  17 
src/main/java/im/conversations/android/xmpp/model/muc/user/Item.java             |  49 
src/main/java/im/conversations/android/xmpp/model/pgp/Signed.java                |   9 
src/main/java/im/conversations/android/xmpp/model/stanza/Iq.java                 |  17 
src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java |  38 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java         |  21 
src/main/res/values/strings.xml                                                  |   2 
60 files changed, 2,253 insertions(+), 1,707 deletions(-)

Detailed changes

src/main/java/de/gultsch/common/FutureMerger.java πŸ”—

@@ -0,0 +1,42 @@
+package de.gultsch.common;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.Collection;
+import java.util.List;
+
+public class FutureMerger {
+
+    public static <T> ListenableFuture<List<T>> successfulAsList(
+            final Collection<ListenableFuture<List<T>>> futures) {
+        return Futures.transform(
+                Futures.successfulAsList(futures),
+                lists -> {
+                    final var builder = new ImmutableList.Builder<T>();
+                    for (final Collection<T> list : lists) {
+                        if (list == null) {
+                            continue;
+                        }
+                        builder.addAll(list);
+                    }
+                    return builder.build();
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public static <T> ListenableFuture<List<T>> allAsList(
+            final Collection<ListenableFuture<Collection<T>>> futures) {
+        return Futures.transform(
+                Futures.allAsList(futures),
+                lists -> {
+                    final var builder = new ImmutableList.Builder<T>();
+                    for (final Collection<T> list : lists) {
+                        builder.addAll(list);
+                    }
+                    return builder.build();
+                },
+                MoreExecutors.directExecutor());
+    }
+}

src/main/java/de/gultsch/common/IntMap.java πŸ”—

@@ -0,0 +1,100 @@
+package de.gultsch.common;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableMap;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+public class IntMap<E> implements Map<E, Integer> {
+
+    private final ImmutableMap<E, Integer> inner;
+
+    public IntMap(ImmutableMap<E, Integer> inner) {
+        this.inner = inner;
+    }
+
+    @Override
+    public int size() {
+        return this.inner.size();
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return this.inner.isEmpty();
+    }
+
+    @Override
+    public boolean containsKey(@Nullable Object key) {
+        return this.inner.containsKey(key);
+    }
+
+    @Override
+    public boolean containsValue(@Nullable Object value) {
+        return this.inner.containsValue(value);
+    }
+
+    @Nullable
+    @Override
+    public Integer get(@Nullable Object key) {
+        return this.inner.get(key);
+    }
+
+    public int getInt(@Nullable E key) {
+        final var value = this.inner.get(key);
+        return value == null ? Integer.MIN_VALUE : value;
+    }
+
+    @Nullable
+    @Override
+    public Integer put(E key, Integer value) {
+        return this.inner.put(key, value);
+    }
+
+    @Nullable
+    @Override
+    public Integer remove(@Nullable Object key) {
+        return this.inner.remove(key);
+    }
+
+    @Override
+    public void putAll(@NonNull Map<? extends E, ? extends Integer> m) {
+        this.inner.putAll(m);
+    }
+
+    @Override
+    public void clear() {
+        this.inner.clear();
+    }
+
+    @NonNull
+    @Override
+    public Set<E> keySet() {
+        return this.inner.keySet();
+    }
+
+    @NonNull
+    @Override
+    public Collection<Integer> values() {
+        return this.inner.values();
+    }
+
+    @NonNull
+    @Override
+    public Set<Entry<E, Integer>> entrySet() {
+        return this.inner.entrySet();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof IntMap<?> intMap)) return false;
+        return Objects.equal(inner, intMap.inner);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(inner);
+    }
+}

src/main/java/eu/siacs/conversations/entities/Account.java πŸ”—

@@ -80,10 +80,6 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     public static final String KEY_SOS_URL = "sos_url";
     public static final String KEY_PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
     protected final JSONObject keys;
-    public final Set<Conversation> pendingConferenceJoins = new HashSet<>();
-    public final Set<Conversation> pendingConferenceLeaves = new HashSet<>();
-    public final Set<Conversation> inProgressConferenceJoins = new HashSet<>();
-    public final Set<Conversation> inProgressConferencePings = new HashSet<>();
     protected Jid jid;
     protected String password;
     protected int options = 0;

src/main/java/eu/siacs/conversations/entities/Bookmark.java πŸ”—

@@ -10,7 +10,6 @@ import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xmpp.Jid;
 import im.conversations.android.xmpp.model.bookmark.Storage;
-import im.conversations.android.xmpp.model.bookmark2.Conference;
 import im.conversations.android.xmpp.model.bookmark2.Extensions;
 import java.lang.ref.WeakReference;
 import java.util.Collections;
@@ -33,7 +32,7 @@ public class Bookmark extends Element implements ListItem {
         this.account = account;
     }
 
-    private Bookmark(Account account) {
+    public Bookmark(Account account) {
         super("conference");
         this.account = account;
     }
@@ -72,31 +71,6 @@ public class Bookmark extends Element implements ListItem {
         return bookmark;
     }
 
-    public static Bookmark parseFromItem(
-            final String id, final Conference conference, final Account account) {
-        if (id == null || conference == null) {
-            return null;
-        }
-        final Bookmark bookmark = new Bookmark(account);
-        bookmark.jid = Jid.Invalid.getNullForInvalid(Jid.ofOrInvalid(id));
-        // TODO verify that we only use bare jids and ignore full jids
-        if (bookmark.jid == null) {
-            return null;
-        }
-
-        // TODO use proper API
-
-        bookmark.setBookmarkName(conference.getAttribute("name"));
-        bookmark.setAutojoin(conference.getAttributeAsBoolean("autojoin"));
-        bookmark.setNick(conference.findChildContent("nick"));
-        bookmark.setPassword(conference.findChildContent("password"));
-        final var extensions = conference.getExtensions();
-        if (extensions != null) {
-            bookmark.extensions = conference.getExtensions();
-        }
-        return bookmark;
-    }
-
     public Extensions getExtensions() {
         return extensions;
     }
@@ -260,4 +234,8 @@ public class Bookmark extends Element implements ListItem {
     public String getAvatarName() {
         return getDisplayName();
     }
+
+    public void setExtensions(Extensions extensions) {
+        this.extensions = extensions;
+    }
 }

src/main/java/eu/siacs/conversations/entities/MucOptions.java πŸ”—

@@ -1,12 +1,12 @@
 package eu.siacs.conversations.entities;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import de.gultsch.common.IntMap;
 import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.utils.JidHelper;
@@ -17,18 +17,39 @@ import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import im.conversations.android.xmpp.model.data.Data;
 import im.conversations.android.xmpp.model.data.Field;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.Item;
+import im.conversations.android.xmpp.model.muc.Role;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Locale;
 import java.util.Objects;
 import java.util.Set;
 
 public class MucOptions {
 
+    private static final IntMap<Affiliation> AFFILIATION_RANKS =
+            new IntMap<>(
+                    new ImmutableMap.Builder<Affiliation, Integer>()
+                            .put(Affiliation.OWNER, 4)
+                            .put(Affiliation.ADMIN, 3)
+                            .put(Affiliation.MEMBER, 2)
+                            .put(Affiliation.NONE, 1)
+                            .put(Affiliation.OUTCAST, 0)
+                            .build());
+
+    private static final IntMap<Role> ROLE_RANKS =
+            new IntMap<>(
+                    new ImmutableMap.Builder<Role, Integer>()
+                            .put(Role.MODERATOR, 3)
+                            .put(Role.PARTICIPANT, 2)
+                            .put(Role.VISITOR, 1)
+                            .put(Role.NONE, 0)
+                            .build());
+
     public static final String STATUS_CODE_SELF_PRESENCE = "110";
     public static final String STATUS_CODE_ROOM_CREATED = "201";
     public static final String STATUS_CODE_BANNED = "301";
@@ -38,6 +59,7 @@ public class MucOptions {
     public static final String STATUS_CODE_LOST_MEMBERSHIP = "322";
     public static final String STATUS_CODE_SHUTDOWN = "332";
     public static final String STATUS_CODE_TECHNICAL_REASONS = "333";
+    // TODO this should be a list
     private final Set<User> users = new HashSet<>();
     private final Conversation conversation;
     public OnRenameListener onRenameListener = null;
@@ -53,8 +75,8 @@ public class MucOptions {
         this.account = conversation.getAccount();
         this.conversation = conversation;
         this.self = new User(this, createJoinJid(getProposedNick()));
-        this.self.affiliation = Affiliation.of(conversation.getAttribute("affiliation"));
-        this.self.role = Role.of(conversation.getAttribute("role"));
+        this.self.affiliation = Item.affiliationOrNone(conversation.getAttribute("affiliation"));
+        this.self.role = Item.roleOrNone(conversation.getAttribute("role"));
     }
 
     public Account getAccount() {
@@ -74,7 +96,8 @@ public class MucOptions {
         synchronized (users) {
             if (user != null && user.getRole() == Role.NONE) {
                 users.remove(user);
-                if (affiliation.ranks(Affiliation.MEMBER)) {
+                if (AFFILIATION_RANKS.getInt(affiliation)
+                        >= AFFILIATION_RANKS.getInt(Affiliation.MEMBER)) {
                     user.affiliation = affiliation;
                     users.add(user);
                 }
@@ -82,8 +105,8 @@ public class MucOptions {
         }
     }
 
-    public void flagNoAutoPushConfiguration() {
-        mAutoPushConfiguration = false;
+    public void setAutoPushConfiguration(final boolean auto) {
+        this.mAutoPushConfiguration = auto;
     }
 
     public boolean autoPushConfiguration() {
@@ -176,7 +199,7 @@ public class MucOptions {
 
     public boolean canInvite() {
         final boolean hasPermission =
-                !membersOnly() || self.getRole().ranks(Role.MODERATOR) || allowInvites();
+                !membersOnly() || self.ranks(Role.MODERATOR) || allowInvites();
         return hasPermission && online();
     }
 
@@ -190,7 +213,7 @@ public class MucOptions {
     }
 
     public boolean canChangeSubject() {
-        return self.getRole().ranks(Role.MODERATOR) || participantsCanChangeSubject();
+        return self.ranks(Role.MODERATOR) || participantsCanChangeSubject();
     }
 
     public boolean participantsCanChangeSubject() {
@@ -216,9 +239,9 @@ public class MucOptions {
         if ("anyone".equals(field.getValue())) {
             return true;
         } else if ("participants".equals(field.getValue())) {
-            return self.getRole().ranks(Role.PARTICIPANT);
+            return self.ranks(Role.PARTICIPANT);
         } else if ("moderators".equals(field.getValue())) {
-            return self.getRole().ranks(Role.MODERATOR);
+            return self.ranks(Role.MODERATOR);
         } else {
             return false;
         }
@@ -232,7 +255,7 @@ public class MucOptions {
     }
 
     public boolean participating() {
-        return self.getRole().ranks(Role.PARTICIPANT) || !moderated();
+        return self.ranks(Role.PARTICIPANT) || !moderated();
     }
 
     public boolean membersOnly() {
@@ -283,7 +306,7 @@ public class MucOptions {
                         user.realJid != null && user.realJid.equals(account.getJid().asBareJid());
                 if (membersOnly()
                         && nonanonymous()
-                        && user.affiliation.ranks(Affiliation.MEMBER)
+                        && user.ranks(Affiliation.MEMBER)
                         && user.realJid != null
                         && !realJidInMuc
                         && !self) {
@@ -332,8 +355,8 @@ public class MucOptions {
                     isOnline
                             && user.getFullJid() != null
                             && user.getFullJid().equals(self.getFullJid());
-            if ((!membersOnly() || user.getAffiliation().ranks(Affiliation.MEMBER))
-                    && user.getAffiliation().outranks(Affiliation.OUTCAST)
+            if ((!membersOnly() || user.ranks(Affiliation.MEMBER))
+                    && user.outranks(Affiliation.OUTCAST)
                     && !fullJidIsSelf) {
                 this.users.add(user);
                 return !realJidFound && user.realJid != null;
@@ -446,8 +469,7 @@ public class MucOptions {
         synchronized (users) {
             ArrayList<User> users = new ArrayList<>();
             for (User user : this.users) {
-                if (!user.isDomain()
-                        && (includeOffline || user.getRole().ranks(Role.PARTICIPANT))) {
+                if (!user.isDomain() && (includeOffline || user.ranks(Role.PARTICIPANT))) {
                     users.add(user);
                 }
             }
@@ -725,7 +747,7 @@ public class MucOptions {
         ArrayList<Jid> members = new ArrayList<>();
         synchronized (users) {
             for (User user : users) {
-                if (user.affiliation.ranks(Affiliation.MEMBER)
+                if (user.ranks(Affiliation.MEMBER)
                         && user.realJid != null
                         && !user.realJid
                                 .asBareJid()
@@ -738,90 +760,6 @@ public class MucOptions {
         return members;
     }
 
-    public enum Affiliation {
-        OWNER(4, R.string.owner),
-        ADMIN(3, R.string.admin),
-        MEMBER(2, R.string.member),
-        OUTCAST(0, R.string.outcast),
-        NONE(1, R.string.no_affiliation);
-
-        private final int resId;
-        private final int rank;
-
-        Affiliation(int rank, int resId) {
-            this.resId = resId;
-            this.rank = rank;
-        }
-
-        public static Affiliation of(@Nullable String value) {
-            if (value == null) {
-                return NONE;
-            }
-            try {
-                return Affiliation.valueOf(value.toUpperCase(Locale.US));
-            } catch (IllegalArgumentException e) {
-                return NONE;
-            }
-        }
-
-        public int getResId() {
-            return resId;
-        }
-
-        @Override
-        @NonNull
-        public String toString() {
-            return name().toLowerCase(Locale.US);
-        }
-
-        public boolean outranks(Affiliation affiliation) {
-            return rank > affiliation.rank;
-        }
-
-        public boolean ranks(Affiliation affiliation) {
-            return rank >= affiliation.rank;
-        }
-    }
-
-    public enum Role {
-        MODERATOR(R.string.moderator, 3),
-        VISITOR(R.string.visitor, 1),
-        PARTICIPANT(R.string.participant, 2),
-        NONE(R.string.no_role, 0);
-
-        private final int resId;
-        private final int rank;
-
-        Role(int resId, int rank) {
-            this.resId = resId;
-            this.rank = rank;
-        }
-
-        public static Role of(@Nullable String value) {
-            if (value == null) {
-                return NONE;
-            }
-            try {
-                return Role.valueOf(value.toUpperCase(Locale.US));
-            } catch (IllegalArgumentException e) {
-                return NONE;
-            }
-        }
-
-        public int getResId() {
-            return resId;
-        }
-
-        @Override
-        public String toString() {
-            return name().toLowerCase(Locale.US);
-        }
-
-        public boolean ranks(Role role) {
-            return rank >= role.rank;
-        }
-    }
-
     public enum Error {
         NO_RESPONSE,
         SERVER_NOT_FOUND,
@@ -873,16 +811,16 @@ public class MucOptions {
             return this.role;
         }
 
-        public void setRole(String role) {
-            this.role = Role.of(role);
+        public void setRole(final Role role) {
+            this.role = role;
         }
 
         public Affiliation getAffiliation() {
             return this.affiliation;
         }
 
-        public void setAffiliation(String affiliation) {
-            this.affiliation = Affiliation.of(affiliation);
+        public void setAffiliation(final Affiliation affiliation) {
+            this.affiliation = affiliation;
         }
 
         public long getPgpKeyId() {
@@ -941,6 +879,10 @@ public class MucOptions {
             return options.getAccount();
         }
 
+        public MucOptions getMucOptions() {
+            return this.options;
+        }
+
         public Conversation getConversation() {
             return options.getConversation();
         }
@@ -992,9 +934,9 @@ public class MucOptions {
 
         @Override
         public int compareTo(@NonNull User another) {
-            if (another.getAffiliation().outranks(getAffiliation())) {
+            if (another.outranks(getAffiliation())) {
                 return 1;
-            } else if (getAffiliation().outranks(another.getAffiliation())) {
+            } else if (outranks(another.getAffiliation())) {
                 return -1;
             } else {
                 return getComparableName().compareToIgnoreCase(another.getComparableName());
@@ -1045,5 +987,19 @@ public class MucOptions {
         public String getOccupantId() {
             return this.occupantId;
         }
+
+        public boolean ranks(final Role role) {
+            return ROLE_RANKS.getInt(this.role) >= ROLE_RANKS.getInt(role);
+        }
+
+        public boolean ranks(final Affiliation affiliation) {
+            return AFFILIATION_RANKS.getInt(this.affiliation)
+                    >= AFFILIATION_RANKS.getInt(affiliation);
+        }
+
+        public boolean outranks(final Affiliation affiliation) {
+            return AFFILIATION_RANKS.getInt(this.affiliation)
+                    > AFFILIATION_RANKS.getInt(affiliation);
+        }
     }
 }

src/main/java/eu/siacs/conversations/generator/IqGenerator.java πŸ”—

@@ -5,7 +5,6 @@ import android.util.Base64;
 import android.util.Log;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
@@ -15,8 +14,6 @@ import eu.siacs.conversations.xmpp.forms.Data;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.security.cert.CertificateEncodingException;
 import java.security.cert.X509Certificate;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Set;
 import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.ecc.ECPublicKey;
@@ -57,13 +54,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq deleteNode(final String node) {
-        final var packet = new Iq(Iq.Type.SET);
-        final Element pubsub = packet.addChild("pubsub", Namespace.PUBSUB_OWNER);
-        pubsub.addChild("delete").setAttribute("node", node);
-        return packet;
-    }
-
     public Iq retrieveDeviceIds(final Jid to) {
         final var packet = retrieve(AxolotlService.PEP_DEVICE_LIST, null);
         if (to != null) {
@@ -152,7 +142,7 @@ public class IqGenerator extends AbstractGenerator {
 
     public Iq queryMessageArchiveManagement(final MessageArchiveService.Query mam) {
         final Iq packet = new Iq(Iq.Type.SET);
-        final Element query = packet.query(mam.version.namespace);
+        final Element query = packet.addChild("query", mam.version.namespace);
         query.setAttribute("queryid", mam.getQueryId());
         final Data data = new Data();
         data.setFormType(mam.version.namespace);
@@ -181,35 +171,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq changeAffiliation(Conversation conference, Jid jid, String affiliation) {
-        List<Jid> jids = new ArrayList<>();
-        jids.add(jid);
-        return changeAffiliation(conference, jids, affiliation);
-    }
-
-    public Iq changeAffiliation(Conversation conference, List<Jid> jids, String affiliation) {
-        final Iq packet = new Iq(Iq.Type.SET);
-        packet.setTo(conference.getJid().asBareJid());
-        packet.setFrom(conference.getAccount().getJid());
-        Element query = packet.query("http://jabber.org/protocol/muc#admin");
-        for (Jid jid : jids) {
-            Element item = query.addChild("item");
-            item.setAttribute("jid", jid);
-            item.setAttribute("affiliation", affiliation);
-        }
-        return packet;
-    }
-
-    public Iq changeRole(Conversation conference, String nick, String role) {
-        final Iq packet = new Iq(Iq.Type.SET);
-        packet.setTo(conference.getJid().asBareJid());
-        packet.setFrom(conference.getAccount().getJid());
-        Element item = packet.query("http://jabber.org/protocol/muc#admin").addChild("item");
-        item.setAttribute("nick", nick);
-        item.setAttribute("role", role);
-        return packet;
-    }
-
     public Iq pushTokenToAppServer(Jid appServer, String token, String deviceId) {
         return pushTokenToAppServer(appServer, token, deviceId, null);
     }
@@ -268,42 +229,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq queryAffiliation(Conversation conversation, String affiliation) {
-        final Iq packet = new Iq(Iq.Type.GET);
-        packet.setTo(conversation.getJid().asBareJid());
-        packet.query("http://jabber.org/protocol/muc#admin")
-                .addChild("item")
-                .setAttribute("affiliation", affiliation);
-        return packet;
-    }
-
-    public static Bundle defaultGroupChatConfiguration() {
-        Bundle options = new Bundle();
-        options.putString("muc#roomconfig_persistentroom", "1");
-        options.putString("muc#roomconfig_membersonly", "1");
-        options.putString("muc#roomconfig_publicroom", "0");
-        options.putString("muc#roomconfig_whois", "anyone");
-        options.putString("muc#roomconfig_changesubject", "0");
-        options.putString("muc#roomconfig_allowinvites", "0");
-        options.putString("muc#roomconfig_enablearchiving", "1"); // prosody
-        options.putString("mam", "1"); // ejabberd community
-        options.putString("muc#roomconfig_mam", "1"); // ejabberd saas
-        return options;
-    }
-
-    public static Bundle defaultChannelConfiguration() {
-        Bundle options = new Bundle();
-        options.putString("muc#roomconfig_persistentroom", "1");
-        options.putString("muc#roomconfig_membersonly", "0");
-        options.putString("muc#roomconfig_publicroom", "1");
-        options.putString("muc#roomconfig_whois", "moderators");
-        options.putString("muc#roomconfig_changesubject", "0");
-        options.putString("muc#roomconfig_enablearchiving", "1"); // prosody
-        options.putString("mam", "1"); // ejabberd community
-        options.putString("muc#roomconfig_mam", "1"); // ejabberd saas
-        return options;
-    }
-
     public Iq requestPubsubConfiguration(Jid jid, String node) {
         return pubsubConfiguration(jid, node, null);
     }

src/main/java/eu/siacs/conversations/generator/MessageGenerator.java πŸ”—

@@ -17,6 +17,7 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 import im.conversations.android.xmpp.model.correction.Replace;
+import im.conversations.android.xmpp.model.hints.NoStore;
 import im.conversations.android.xmpp.model.hints.Store;
 import im.conversations.android.xmpp.model.reactions.Reaction;
 import im.conversations.android.xmpp.model.reactions.Reactions;
@@ -95,7 +96,7 @@ public class MessageGenerator extends AbstractGenerator {
         }
         packet.setAxolotlMessage(axolotlMessage.toElement());
         packet.setBody(OMEMO_FALLBACK_MESSAGE);
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         packet.addChild("encryption", "urn:xmpp:eme:0")
                 .setAttribute("name", "OMEMO")
                 .setAttribute("namespace", AxolotlService.PEP_PREFIX);
@@ -109,7 +110,7 @@ public class MessageGenerator extends AbstractGenerator {
         packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
         packet.setTo(to);
         packet.setAxolotlMessage(axolotlMessage.toElement());
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addChild(new Store());
         return packet;
     }
 
@@ -161,8 +162,7 @@ public class MessageGenerator extends AbstractGenerator {
         packet.setTo(conversation.getJid().asBareJid());
         packet.setFrom(account.getJid());
         packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
-        packet.addChild("no-store", "urn:xmpp:hints");
-        packet.addChild("no-storage", "urn:xmpp:hints"); // wrong! don't copy this. Its *store*
+        packet.addExtension(new NoStore());
         return packet;
     }
 
@@ -188,7 +188,7 @@ public class MessageGenerator extends AbstractGenerator {
         } else {
             displayed.setAttribute("id", message.getRemoteMsgId());
         }
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         return packet;
     }
 
@@ -209,52 +209,7 @@ public class MessageGenerator extends AbstractGenerator {
         for (final String ourReaction : ourReactions) {
             reactions.addExtension(new Reaction(ourReaction));
         }
-        packet.addChild("store", "urn:xmpp:hints");
-        return packet;
-    }
-
-    public im.conversations.android.xmpp.model.stanza.Message conferenceSubject(
-            Conversation conversation, String subject) {
-        im.conversations.android.xmpp.model.stanza.Message packet =
-                new im.conversations.android.xmpp.model.stanza.Message();
-        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT);
-        packet.setTo(conversation.getJid().asBareJid());
-        packet.addChild("subject").setContent(subject);
-        packet.setFrom(conversation.getAccount().getJid().asBareJid());
-        return packet;
-    }
-
-    public im.conversations.android.xmpp.model.stanza.Message directInvite(
-            final Conversation conversation, final Jid contact) {
-        im.conversations.android.xmpp.model.stanza.Message packet =
-                new im.conversations.android.xmpp.model.stanza.Message();
-        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.NORMAL);
-        packet.setTo(contact);
-        packet.setFrom(conversation.getAccount().getJid());
-        Element x = packet.addChild("x", "jabber:x:conference");
-        x.setAttribute("jid", conversation.getJid().asBareJid());
-        String password = conversation.getMucOptions().getPassword();
-        if (password != null) {
-            x.setAttribute("password", password);
-        }
-        if (contact.isFullJid()) {
-            packet.addChild("no-store", "urn:xmpp:hints");
-            packet.addChild("no-copy", "urn:xmpp:hints");
-        }
-        return packet;
-    }
-
-    public im.conversations.android.xmpp.model.stanza.Message invite(
-            final Conversation conversation, final Jid contact) {
-        final var packet = new im.conversations.android.xmpp.model.stanza.Message();
-        packet.setTo(conversation.getJid().asBareJid());
-        packet.setFrom(conversation.getAccount().getJid());
-        Element x = new Element("x");
-        x.setAttribute("xmlns", "http://jabber.org/protocol/muc#user");
-        Element invite = new Element("invite");
-        invite.setAttribute("to", contact.asBareJid());
-        x.addChild(invite);
-        packet.addChild(x);
+        packet.addExtension(new Store());
         return packet;
     }
 
@@ -277,7 +232,7 @@ public class MessageGenerator extends AbstractGenerator {
         packet.setFrom(account.getJid());
         packet.setTo(to);
         packet.addChild("received", "urn:xmpp:receipts").setAttribute("id", id);
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         return packet;
     }
 
@@ -291,7 +246,7 @@ public class MessageGenerator extends AbstractGenerator {
         finish.setAttribute("id", sessionId);
         final Element reasonElement = finish.addChild("reason", Namespace.JINGLE);
         reasonElement.addChild(reason.toString());
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         return packet;
     }
 
@@ -311,7 +266,7 @@ public class MessageGenerator extends AbstractGenerator {
                     .setAttribute("media", media.toString());
         }
         packet.addChild("request", "urn:xmpp:receipts");
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         return packet;
     }
 
@@ -326,7 +281,7 @@ public class MessageGenerator extends AbstractGenerator {
         final Element propose = packet.addChild("retract", Namespace.JINGLE_MESSAGE);
         propose.setAttribute("id", proposal.sessionId);
         propose.addChild("description", Namespace.JINGLE_APPS_RTP);
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         return packet;
     }
 
@@ -341,7 +296,7 @@ public class MessageGenerator extends AbstractGenerator {
         final Element propose = packet.addChild("reject", Namespace.JINGLE_MESSAGE);
         propose.setAttribute("id", sessionId);
         propose.addChild("description", Namespace.JINGLE_APPS_RTP);
-        packet.addChild("store", "urn:xmpp:hints");
+        packet.addExtension(new Store());
         return packet;
     }
 }

src/main/java/eu/siacs/conversations/parser/AbstractParser.java πŸ”—

@@ -133,7 +133,7 @@ public abstract class AbstractParser extends XmppConnection.Delegate {
         return item.findChildContent("data", "urn:xmpp:avatar:data");
     }
 
-    public static MucOptions.User parseItem(Conversation conference, Element item) {
+    public static MucOptions.User parseItem(final Conversation conference, final Element item) {
         return parseItem(conference, item, null);
     }
 
@@ -156,8 +156,8 @@ public abstract class AbstractParser extends XmppConnection.Delegate {
         if (Jid.Invalid.isValid(realJid)) {
             user.setRealJid(realJid);
         }
-        user.setAffiliation(affiliation);
-        user.setRole(role);
+        // user.setAffiliation(affiliation);
+        // user.setRole(role);
         return user;
     }
 

src/main/java/eu/siacs/conversations/parser/MessageParser.java πŸ”—

@@ -34,15 +34,18 @@ import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import eu.siacs.conversations.xmpp.manager.PubSubManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.axolotl.Encrypted;
 import im.conversations.android.xmpp.model.carbons.Received;
 import im.conversations.android.xmpp.model.carbons.Sent;
+import im.conversations.android.xmpp.model.conference.DirectInvite;
 import im.conversations.android.xmpp.model.correction.Replace;
 import im.conversations.android.xmpp.model.forward.Forwarded;
 import im.conversations.android.xmpp.model.markers.Displayed;
+import im.conversations.android.xmpp.model.muc.user.MucUser;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
 import im.conversations.android.xmpp.model.oob.OutOfBandData;
 import im.conversations.android.xmpp.model.pubsub.event.Event;
@@ -212,7 +215,7 @@ public class MessageParser extends AbstractParser
         return null;
     }
 
-    private Invite extractInvite(final Element message) {
+    private Invite extractInvite(final im.conversations.android.xmpp.model.stanza.Message message) {
         final Element mucUser = message.findChild("x", Namespace.MUC_USER);
         if (mucUser != null) {
             final Element invite = mucUser.findChild("invite");
@@ -231,7 +234,7 @@ public class MessageParser extends AbstractParser
                 return new Invite(room, password, false, from);
             }
         }
-        final Element conference = message.findChild("x", "jabber:x:conference");
+        final var conference = message.getExtension(DirectInvite.class);
         if (conference != null) {
             Jid from = Jid.Invalid.getNullForInvalid(message.getAttributeAsJid("from"));
             Jid room = Jid.Invalid.getNullForInvalid(conference.getAttributeAsJid("jid"));
@@ -303,7 +306,7 @@ public class MessageParser extends AbstractParser
                                             + ": received ping worthy error for seemingly online"
                                             + " muc at "
                                             + from);
-                            mXmppConnectionService.mucSelfPingAndRejoin(conversation);
+                            getManager(MultiUserChatManager.class).pingAndRejoin(conversation);
                         }
                     }
                 }
@@ -347,6 +350,12 @@ public class MessageParser extends AbstractParser
             packet = f.first;
             serverMsgId = result.getAttribute("id");
             query.incrementMessageCount();
+
+            if (query.isImplausibleFrom(packet.getFrom())) {
+                Log.d(Config.LOGTAG, "found implausible from in MUC MAM archive");
+                return;
+            }
+
             if (handleErrorMessage(account, packet)) {
                 return;
             }
@@ -520,7 +529,7 @@ public class MessageParser extends AbstractParser
                             || mucUserElement != null
                             || connection
                                     .getMucServersWithholdAccount()
-                                    .contains(counterpart.getDomain().toString());
+                                    .contains(counterpart.getDomain());
             final Conversation conversation =
                     mXmppConnectionService.findOrCreateConversation(
                             account,
@@ -1009,69 +1018,11 @@ public class MessageParser extends AbstractParser
                     }
                 }
             }
-            if (conversation != null
-                    && mucUserElement != null
-                    && Jid.Invalid.hasValidFrom(packet)
-                    && from.isBareJid()) {
-                for (Element child : mucUserElement.getChildren()) {
-                    if ("status".equals(child.getName())) {
-                        try {
-                            int code = Integer.parseInt(child.getAttribute("code"));
-                            if ((code >= 170 && code <= 174) || (code >= 102 && code <= 104)) {
-                                mXmppConnectionService.fetchConferenceConfiguration(conversation);
-                                break;
-                            }
-                        } catch (Exception e) {
-                            // ignored
-                        }
-                    } else if ("item".equals(child.getName())) {
-                        final var user = AbstractParser.parseItem(conversation, child);
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid()
-                                        + ": changing affiliation for "
-                                        + user.getRealJid()
-                                        + " to "
-                                        + user.getAffiliation()
-                                        + " in "
-                                        + conversation.getJid().asBareJid());
-                        if (!user.realJidMatchesAccount()) {
-                            final var mucOptions = conversation.getMucOptions();
-                            final boolean isNew = mucOptions.updateUser(user);
-                            final var avatarService = mXmppConnectionService.getAvatarService();
-                            if (Strings.isNullOrEmpty(mucOptions.getAvatar())) {
-                                avatarService.clear(mucOptions);
-                            }
-                            avatarService.clear(user);
-                            mXmppConnectionService.updateMucRosterUi();
-                            mXmppConnectionService.updateConversationUi();
-                            Contact contact = user.getContact();
-                            if (!user.getAffiliation().ranks(MucOptions.Affiliation.MEMBER)) {
-                                Jid jid = user.getRealJid();
-                                List<Jid> cryptoTargets = conversation.getAcceptedCryptoTargets();
-                                if (cryptoTargets.remove(user.getRealJid())) {
-                                    Log.d(
-                                            Config.LOGTAG,
-                                            account.getJid().asBareJid()
-                                                    + ": removed "
-                                                    + jid
-                                                    + " from crypto targets of "
-                                                    + conversation.getName());
-                                    conversation.setAcceptedCryptoTargets(cryptoTargets);
-                                    mXmppConnectionService.updateConversation(conversation);
-                                }
-                            } else if (isNew
-                                    && user.getRealJid() != null
-                                    && conversation.getMucOptions().isPrivateAndNonAnonymous()
-                                    && (contact == null || !contact.mutualPresenceSubscription())
-                                    && account.getAxolotlService()
-                                            .hasEmptyDeviceList(user.getRealJid())) {
-                                account.getAxolotlService().fetchDeviceIds(user.getRealJid());
-                            }
-                        }
-                    }
-                }
+
+            if (original.hasExtension(MucUser.class)) {
+                getManager(MultiUserChatManager.class).handleStatusMessage(original);
             }
+
             if (!isTypeGroupChat) {
                 for (Element child : packet.getChildren()) {
                     if (Namespace.JINGLE_MESSAGE.equals(child.getNamespace())
@@ -1239,7 +1190,7 @@ public class MessageParser extends AbstractParser
         }
 
         final String nick = packet.findChildContent("nick", Namespace.NICK);
-        if (nick != null && Jid.Invalid.hasValidFrom(original)) {
+        if (nick != null && Jid.Invalid.isValid(from)) {
             if (mXmppConnectionService.isMuc(account, from)) {
                 return;
             }
@@ -1599,12 +1550,15 @@ public class MessageParser extends AbstractParser
                                     + ": received invite to "
                                     + jid
                                     + " but muc is considered to be online");
-                    mXmppConnectionService.mucSelfPingAndRejoin(conversation);
+                    getManager(MultiUserChatManager.class).pingAndRejoin(conversation);
                 } else {
                     conversation.getMucOptions().setPassword(password);
                     mXmppConnectionService.databaseBackend.updateConversation(conversation);
-                    mXmppConnectionService.joinMuc(
-                            conversation, contact != null && contact.showInContactList());
+                    if (contact != null && contact.showInContactList()) {
+                        getManager(MultiUserChatManager.class).joinFollowingInvite(conversation);
+                    } else {
+                        getManager(MultiUserChatManager.class).join(conversation);
+                    }
                     mXmppConnectionService.updateConversationUi();
                 }
                 return true;

src/main/java/eu/siacs/conversations/parser/PresenceParser.java πŸ”—

@@ -15,7 +15,6 @@ import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
-import eu.siacs.conversations.generator.IqGenerator;
 import eu.siacs.conversations.generator.PresenceGenerator;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.XmppUri;
@@ -25,9 +24,11 @@ import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.manager.AvatarManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import im.conversations.android.xmpp.Entity;
+import im.conversations.android.xmpp.model.muc.user.MucUser;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
 import im.conversations.android.xmpp.model.vcard.update.VCardUpdate;
 import java.util.ArrayList;
@@ -81,15 +82,16 @@ public class PresenceParser extends AbstractParser
         final Jid from = packet.getFrom();
         if (!from.isBareJid()) {
             final String type = packet.getAttribute("type");
-            final Element x = packet.findChild("x", Namespace.MUC_USER);
+            final var x = packet.getExtension(MucUser.class);
             final var vCardUpdate = packet.getExtension(VCardUpdate.class);
             final List<String> codes = getStatusCodes(x);
             if (type == null) {
                 if (x != null) {
-                    Element item = x.findChild("item");
+                    final var item = x.getItem();
                     if (item != null && !from.isBareJid()) {
                         mucOptions.setError(MucOptions.Error.NONE);
-                        final MucOptions.User user = parseItem(conversation, item, from);
+                        final MucOptions.User user =
+                                MultiUserChatManager.itemToUser(conversation, item, from);
                         final var occupant = packet.getOnlyExtension(OccupantId.class);
                         final String occupantId =
                                 mucOptions.occupantId() && occupant != null
@@ -135,16 +137,17 @@ public class PresenceParser extends AbstractParser
                         }
                         if (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED)
                                 && mucOptions.autoPushConfiguration()) {
+                            final var address = mucOptions.getConversation().getJid().asBareJid();
                             Log.d(
                                     Config.LOGTAG,
                                     account.getJid().asBareJid()
                                             + ": room '"
-                                            + mucOptions.getConversation().getJid().asBareJid()
+                                            + address
                                             + "' created. pushing default configuration");
-                            mXmppConnectionService.pushConferenceConfiguration(
-                                    mucOptions.getConversation(),
-                                    IqGenerator.defaultChannelConfiguration(),
-                                    null);
+                            getManager(MultiUserChatManager.class)
+                                    .pushConfiguration(
+                                            conversation,
+                                            MultiUserChatManager.defaultChannelConfiguration());
                         }
                         if (mXmppConnectionService.getPgpEngine() != null) {
                             Element signed = packet.findChild("x", "jabber:x:signed");
@@ -199,7 +202,7 @@ public class PresenceParser extends AbstractParser
                                         + " online="
                                         + wasOnline);
                         if (wasOnline) {
-                            mXmppConnectionService.mucSelfPingAndRejoin(conversation);
+                            getManager(MultiUserChatManager.class).pingAndRejoin(conversation);
                         }
                     } else if (codes.contains(MucOptions.STATUS_CODE_KICKED)) {
                         mucOptions.setError(MucOptions.Error.KICKED);
@@ -216,9 +219,10 @@ public class PresenceParser extends AbstractParser
                         Log.d(Config.LOGTAG, "unknown error in conference: " + packet);
                     }
                 } else if (!from.isBareJid()) {
-                    Element item = x.findChild("item");
+                    final var item = x.getItem();
                     if (item != null) {
-                        mucOptions.updateUser(parseItem(conversation, item, from));
+                        mucOptions.updateUser(
+                                MultiUserChatManager.itemToUser(conversation, item, from));
                     }
                     MucOptions.User user = mucOptions.deleteUser(from);
                     if (user != null) {

src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java πŸ”—

@@ -19,6 +19,7 @@ import eu.siacs.conversations.http.HttpConnectionManager;
 import eu.siacs.conversations.http.services.MuclumbusService;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.disco.items.Item;
 import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
@@ -304,10 +305,10 @@ public class ChannelDiscoveryService {
         for (final var account : service.getAccounts()) {
             final var connection = account.getXmppConnection();
             if (connection != null && account.isEnabled()) {
-                for (final String mucService : connection.getMucServers()) {
-                    final Jid jid = Jid.ofOrInvalid(mucService);
-                    if (Jid.Invalid.isValid(jid)) {
-                        localMucServices.put(jid, connection);
+                for (final var mucService :
+                        connection.getManager(MultiUserChatManager.class).getServices()) {
+                    if (Jid.Invalid.isValid(mucService)) {
+                        localMucServices.put(mucService, connection);
                     }
                 }
             }

src/main/java/eu/siacs/conversations/services/MessageArchiveService.java πŸ”—

@@ -134,7 +134,7 @@ public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
         this.execute(query);
     }
 
-    void catchupMUC(final Conversation conversation) {
+    public void catchupMUC(final Conversation conversation) {
         if (conversation.getLastMessageTransmitted().getTimestamp() < 0
                 && conversation.countMessages() == 0) {
             query(conversation, new MamReference(0), 0, true);
@@ -749,5 +749,16 @@ public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
         boolean hasCallback() {
             return this.callback != null;
         }
+
+        public boolean isImplausibleFrom(final Jid from) {
+            if (muc()) {
+                if (from == null) {
+                    return true;
+                }
+                return !from.asBareJid().equals(getWith());
+            } else {
+                return false;
+            }
+        }
     }
 }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java πŸ”—

@@ -37,7 +37,6 @@ import android.os.SystemClock;
 import android.preference.PreferenceManager;
 import android.provider.ContactsContract;
 import android.security.KeyChain;
-import android.text.TextUtils;
 import android.util.Log;
 import android.util.LruCache;
 import android.util.Pair;
@@ -76,7 +75,6 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
-import eu.siacs.conversations.entities.MucOptions.OnRenameListener;
 import eu.siacs.conversations.entities.PresenceTemplate;
 import eu.siacs.conversations.entities.Reaction;
 import eu.siacs.conversations.generator.AbstractGenerator;
@@ -85,7 +83,6 @@ import eu.siacs.conversations.generator.MessageGenerator;
 import eu.siacs.conversations.generator.PresenceGenerator;
 import eu.siacs.conversations.http.HttpConnectionManager;
 import eu.siacs.conversations.http.ServiceOutageStatus;
-import eu.siacs.conversations.parser.AbstractParser;
 import eu.siacs.conversations.parser.IqParser;
 import eu.siacs.conversations.persistance.DatabaseBackend;
 import eu.siacs.conversations.persistance.FileBackend;
@@ -110,7 +107,6 @@ import eu.siacs.conversations.utils.QuickLoader;
 import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
 import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
-import eu.siacs.conversations.utils.StringUtils;
 import eu.siacs.conversations.utils.TorServiceUtils;
 import eu.siacs.conversations.utils.WakeLockHelper;
 import eu.siacs.conversations.utils.XmppUri;
@@ -133,21 +129,19 @@ import eu.siacs.conversations.xmpp.manager.AvatarManager;
 import eu.siacs.conversations.xmpp.manager.BlockingManager;
 import eu.siacs.conversations.xmpp.manager.BookmarkManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
-import eu.siacs.conversations.xmpp.manager.LegacyBookmarkManager;
 import eu.siacs.conversations.xmpp.manager.MessageDisplayedSynchronizationManager;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import eu.siacs.conversations.xmpp.manager.NickManager;
+import eu.siacs.conversations.xmpp.manager.PepManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
-import eu.siacs.conversations.xmpp.manager.PrivateStorageManager;
 import eu.siacs.conversations.xmpp.manager.RegistrationManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import eu.siacs.conversations.xmpp.manager.VCardManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
-import im.conversations.android.xmpp.Entity;
-import im.conversations.android.xmpp.IqErrorException;
-import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.Role;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import im.conversations.android.xmpp.model.up.Push;
-import im.conversations.android.xmpp.model.vcard.update.VCardUpdate;
 import java.io.File;
 import java.security.Security;
 import java.security.cert.CertificateException;
@@ -159,7 +153,6 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
-import java.util.ListIterator;
 import java.util.Map;
 import java.util.Set;
 import java.util.WeakHashMap;
@@ -1652,7 +1645,10 @@ public class XmppConnectionService extends Service {
             }
         }
 
-        final boolean inProgressJoin = isJoinInProgress(conversation);
+        final boolean inProgressJoin =
+                account.getXmppConnection()
+                        .getManager(MultiUserChatManager.class)
+                        .isJoinInProgress(conversation);
 
         if (account.isOnlineAndConnected() && !inProgressJoin) {
             switch (message.getEncryption()) {
@@ -1786,29 +1782,6 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    private boolean isJoinInProgress(final Conversation conversation) {
-        final Account account = conversation.getAccount();
-        synchronized (account.inProgressConferenceJoins) {
-            if (conversation.getMode() == Conversational.MODE_MULTI) {
-                final boolean inProgress = account.inProgressConferenceJoins.contains(conversation);
-                final boolean pending = account.pendingConferenceJoins.contains(conversation);
-                final boolean inProgressJoin = inProgress || pending;
-                if (inProgressJoin) {
-                    Log.d(
-                            Config.LOGTAG,
-                            account.getJid().asBareJid()
-                                    + ": holding back message to group. inProgress="
-                                    + inProgress
-                                    + ", pending="
-                                    + pending);
-                }
-                return inProgressJoin;
-            } else {
-                return false;
-            }
-        }
-    }
-
     public void sendUnsentMessages(final Conversation conversation) {
         conversation.findWaitingMessages(message -> resendMessage(message, true));
     }
@@ -1897,141 +1870,12 @@ public class XmppConnectionService extends Service {
         return true;
     }
 
-    public void processModifiedBookmark(final Bookmark bookmark, final boolean pep) {
-        final Account account = bookmark.getAccount();
-        Conversation conversation = find(bookmark);
-        if (conversation != null) {
-            if (conversation.getMode() != Conversation.MODE_MULTI) {
-                return;
-            }
-            bookmark.setConversation(conversation);
-            if (pep && !bookmark.autojoin()) {
-                Log.d(
-                        Config.LOGTAG,
-                        account.getJid().asBareJid()
-                                + ": archiving conference ("
-                                + conversation.getJid()
-                                + ") after receiving pep");
-                archiveConversation(conversation, false);
-            } else {
-                final MucOptions mucOptions = conversation.getMucOptions();
-                if (mucOptions.getError() == MucOptions.Error.NICK_IN_USE) {
-                    final String current = mucOptions.getActualNick();
-                    final String proposed = mucOptions.getProposedNickPure();
-                    if (current != null && !current.equals(proposed)) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": proposed nick changed after bookmark push "
-                                        + current
-                                        + "->"
-                                        + proposed);
-                        joinMuc(conversation);
-                    }
-                } else {
-                    checkMucRequiresRename(conversation);
-                }
-            }
-        } else if (bookmark.autojoin()) {
-            conversation =
-                    findOrCreateConversation(account, bookmark.getFullJid(), true, true, false);
-            bookmark.setConversation(conversation);
-        }
-    }
-
-    public void processModifiedBookmark(final Bookmark bookmark) {
-        processModifiedBookmark(bookmark, true);
-    }
-
-    public void ensureBookmarkIsAutoJoin(final Conversation conversation) {
-        final var account = conversation.getAccount();
-        final var existingBookmark = conversation.getBookmark();
-        if (existingBookmark == null) {
-            final var bookmark = new Bookmark(account, conversation.getJid().asBareJid());
-            bookmark.setAutojoin(true);
-            createBookmark(account, bookmark);
-        } else {
-            if (existingBookmark.autojoin()) {
-                return;
-            }
-            existingBookmark.setAutojoin(true);
-            createBookmark(account, existingBookmark);
-        }
-    }
-
     public void createBookmark(final Account account, final Bookmark bookmark) {
-        account.putBookmark(bookmark);
-        final XmppConnection connection = account.getXmppConnection();
-        final ListenableFuture<Void> future;
-        if (connection.getManager(BookmarkManager.class).hasFeature()) {
-            future = connection.getManager(BookmarkManager.class).publish(bookmark);
-        } else if (connection.getManager(LegacyBookmarkManager.class).hasConversion()) {
-            future =
-                    connection
-                            .getManager(LegacyBookmarkManager.class)
-                            .publish(account.getBookmarks());
-        } else {
-            future =
-                    connection
-                            .getManager(PrivateStorageManager.class)
-                            .publishBookmarks(account.getBookmarks());
-        }
-        Futures.addCallback(
-                future,
-                new FutureCallback<Void>() {
-                    @Override
-                    public void onSuccess(Void result) {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": created bookmark");
-                    }
-
-                    @Override
-                    public void onFailure(@NonNull Throwable t) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid() + ": could not create bookmark",
-                                t);
-                    }
-                },
-                MoreExecutors.directExecutor());
+        account.getXmppConnection().getManager(BookmarkManager.class).create(bookmark);
     }
 
     public void deleteBookmark(final Account account, final Bookmark bookmark) {
-        account.removeBookmark(bookmark);
-        final XmppConnection connection = account.getXmppConnection();
-        final ListenableFuture<Void> future;
-        if (connection.getManager(BookmarkManager.class).hasFeature()) {
-            future =
-                    connection
-                            .getManager(BookmarkManager.class)
-                            .retract(bookmark.getJid().asBareJid());
-        } else if (connection.getManager(LegacyBookmarkManager.class).hasConversion()) {
-            future =
-                    connection
-                            .getManager(LegacyBookmarkManager.class)
-                            .publish(account.getBookmarks());
-        } else {
-            future =
-                    connection
-                            .getManager(PrivateStorageManager.class)
-                            .publishBookmarks(account.getBookmarks());
-        }
-        Futures.addCallback(
-                future,
-                new FutureCallback<Void>() {
-                    @Override
-                    public void onSuccess(Void result) {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmark");
-                    }
-
-                    @Override
-                    public void onFailure(@NonNull Throwable t) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid() + ": could not delete bookmark",
-                                t);
-                    }
-                },
-                MoreExecutors.directExecutor());
+        account.getXmppConnection().getManager(BookmarkManager.class).delete(bookmark);
     }
 
     private void restoreFromDatabase() {
@@ -2475,7 +2319,9 @@ public class XmppConnectionService extends Service {
                                 null));
         this.conversations.add(existing);
         if (existing.getMode() == Conversational.MODE_MULTI) {
-            ensureBookmarkIsAutoJoin(existing);
+            account.getXmppConnection()
+                    .getManager(BookmarkManager.class)
+                    .ensureBookmarkIsAutoJoin(existing);
         }
         updateConversationUi();
         return existing;
@@ -2534,17 +2380,20 @@ public class XmppConnectionService extends Service {
 
     public void archiveConversation(
             Conversation conversation, final boolean maySynchronizeWithBookmarks) {
+        final var account = conversation.getAccount();
+        final var connection = account.getXmppConnection();
         getNotificationService().clear(conversation);
         conversation.setStatus(Conversation.STATUS_ARCHIVED);
         conversation.setNextMessage(null);
         synchronized (this.conversations) {
             getMessageArchiveService().kill(conversation);
             if (conversation.getMode() == Conversation.MODE_MULTI) {
+                // TODO always clean up bookmarks no matter if we are currently connected
+                // TODO always delete reference to conversation in bookmark
                 if (conversation.getAccount().getStatus() == Account.State.ONLINE) {
                     final Bookmark bookmark = conversation.getBookmark();
                     if (maySynchronizeWithBookmarks && bookmark != null) {
                         if (conversation.getMucOptions().getError() == MucOptions.Error.DESTROYED) {
-                            Account account = bookmark.getAccount();
                             bookmark.setConversation(null);
                             deleteBookmark(account, bookmark);
                         } else if (bookmark.autojoin()) {
@@ -2553,7 +2402,7 @@ public class XmppConnectionService extends Service {
                         }
                     }
                 }
-                leaveMuc(conversation);
+                connection.getManager(MultiUserChatManager.class).leave(conversation);
             } else {
                 if (conversation
                         .getContact()
@@ -2753,7 +2602,9 @@ public class XmppConnectionService extends Service {
                 if (conversation.getAccount() == account) {
                     if (conversation.getMode() == Conversation.MODE_MULTI) {
                         if (connected) {
-                            leaveMuc(conversation);
+                            account.getXmppConnection()
+                                    .getManager(MultiUserChatManager.class)
+                                    .unavailable(conversation);
                         }
                     }
                     conversations.remove(conversation);
@@ -3101,344 +2952,16 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public void mucSelfPingAndRejoin(final Conversation conversation) {
-        final Account account = conversation.getAccount();
-        synchronized (account.inProgressConferenceJoins) {
-            if (account.inProgressConferenceJoins.contains(conversation)) {
-                Log.d(
-                        Config.LOGTAG,
-                        account.getJid().asBareJid()
-                                + ": canceling muc self ping because join is already under way");
-                return;
-            }
-        }
-        synchronized (account.inProgressConferencePings) {
-            if (!account.inProgressConferencePings.add(conversation)) {
-                Log.d(
-                        Config.LOGTAG,
-                        account.getJid().asBareJid()
-                                + ": canceling muc self ping because ping is already under way");
-                return;
-            }
-        }
-        // TODO use PingManager
-        final Jid self = conversation.getMucOptions().getSelf().getFullJid();
-        final Iq ping = new Iq(Iq.Type.GET);
-        ping.setTo(self);
-        ping.addChild("ping", Namespace.PING);
-        sendIqPacket(
-                conversation.getAccount(),
-                ping,
-                (response) -> {
-                    if (response.getType() == Iq.Type.ERROR) {
-                        final var error = response.getError();
-                        if (error == null
-                                || error.hasChild("service-unavailable")
-                                || error.hasChild("feature-not-implemented")
-                                || error.hasChild("item-not-found")) {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": ping to "
-                                            + self
-                                            + " came back as ignorable error");
-                        } else {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": ping to "
-                                            + self
-                                            + " failed. attempting rejoin");
-                            joinMuc(conversation);
-                        }
-                    } else if (response.getType() == Iq.Type.RESULT) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": ping to "
-                                        + self
-                                        + " came back fine");
-                    }
-                    synchronized (account.inProgressConferencePings) {
-                        account.inProgressConferencePings.remove(conversation);
-                    }
-                });
-    }
-
-    public void joinMuc(Conversation conversation) {
-        joinMuc(conversation, null, false);
-    }
-
-    public void joinMuc(Conversation conversation, boolean followedInvite) {
-        joinMuc(conversation, null, followedInvite);
-    }
-
-    private void joinMuc(Conversation conversation, final OnConferenceJoined onConferenceJoined) {
-        joinMuc(conversation, onConferenceJoined, false);
-    }
-
-    private void joinMuc(
-            final Conversation conversation,
-            final OnConferenceJoined onConferenceJoined,
-            final boolean followedInvite) {
-        final Account account = conversation.getAccount();
-        synchronized (account.pendingConferenceJoins) {
-            account.pendingConferenceJoins.remove(conversation);
-        }
-        synchronized (account.pendingConferenceLeaves) {
-            account.pendingConferenceLeaves.remove(conversation);
-        }
-        if (account.getStatus() == Account.State.ONLINE) {
-            synchronized (account.inProgressConferenceJoins) {
-                account.inProgressConferenceJoins.add(conversation);
-            }
-            if (Config.MUC_LEAVE_BEFORE_JOIN) {
-                sendPresencePacket(account, mPresenceGenerator.leave(conversation.getMucOptions()));
-            }
-            conversation.resetMucOptions();
-            if (onConferenceJoined != null) {
-                conversation.getMucOptions().flagNoAutoPushConfiguration();
-            }
-            conversation.setHasMessagesLeftOnServer(false);
-            fetchConferenceConfiguration(
-                    conversation,
-                    new OnConferenceConfigurationFetched() {
-
-                        private void join(Conversation conversation) {
-                            Account account = conversation.getAccount();
-                            final MucOptions mucOptions = conversation.getMucOptions();
-
-                            if (mucOptions.nonanonymous()
-                                    && !mucOptions.membersOnly()
-                                    && !conversation.getBooleanAttribute(
-                                            "accept_non_anonymous", false)) {
-                                synchronized (account.inProgressConferenceJoins) {
-                                    account.inProgressConferenceJoins.remove(conversation);
-                                }
-                                mucOptions.setError(MucOptions.Error.NON_ANONYMOUS);
-                                updateConversationUi();
-                                if (onConferenceJoined != null) {
-                                    onConferenceJoined.onConferenceJoined(conversation);
-                                }
-                                return;
-                            }
-
-                            final Jid joinJid = mucOptions.getSelf().getFullJid();
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid().toString()
-                                            + ": joining conversation "
-                                            + joinJid.toString());
-                            final var packet =
-                                    mPresenceGenerator.selfPresence(
-                                            account,
-                                            im.conversations.android.xmpp.model.stanza.Presence
-                                                    .Availability.ONLINE,
-                                            mucOptions.nonanonymous()
-                                                    || onConferenceJoined != null);
-                            packet.setTo(joinJid);
-                            Element x = packet.addChild("x", "http://jabber.org/protocol/muc");
-                            if (conversation.getMucOptions().getPassword() != null) {
-                                x.addChild("password").setContent(mucOptions.getPassword());
-                            }
-
-                            if (mucOptions.mamSupport()) {
-                                // Use MAM instead of the limited muc history to get history
-                                x.addChild("history").setAttribute("maxchars", "0");
-                            } else {
-                                // Fallback to muc history
-                                x.addChild("history")
-                                        .setAttribute(
-                                                "since",
-                                                PresenceGenerator.getTimestamp(
-                                                        conversation
-                                                                .getLastMessageTransmitted()
-                                                                .getTimestamp()));
-                            }
-                            sendPresencePacket(account, packet);
-                            if (onConferenceJoined != null) {
-                                onConferenceJoined.onConferenceJoined(conversation);
-                            }
-                            if (!joinJid.equals(conversation.getJid())) {
-                                conversation.setContactJid(joinJid);
-                                databaseBackend.updateConversation(conversation);
-                            }
-
-                            if (mucOptions.mamSupport()) {
-                                getMessageArchiveService().catchupMUC(conversation);
-                            }
-                            if (mucOptions.isPrivateAndNonAnonymous()) {
-                                fetchConferenceMembers(conversation);
-
-                                if (followedInvite) {
-                                    final Bookmark bookmark = conversation.getBookmark();
-                                    if (bookmark != null) {
-                                        if (!bookmark.autojoin()) {
-                                            bookmark.setAutojoin(true);
-                                            createBookmark(account, bookmark);
-                                        }
-                                    } else {
-                                        saveConversationAsBookmark(conversation, null);
-                                    }
-                                }
-                            }
-                            synchronized (account.inProgressConferenceJoins) {
-                                account.inProgressConferenceJoins.remove(conversation);
-                                sendUnsentMessages(conversation);
-                            }
-                        }
-
-                        @Override
-                        public void onConferenceConfigurationFetched(Conversation conversation) {
-                            if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
-                                Log.d(
-                                        Config.LOGTAG,
-                                        account.getJid().asBareJid()
-                                                + ": conversation ("
-                                                + conversation.getJid()
-                                                + ") got archived before IQ result");
-                                return;
-                            }
-                            join(conversation);
-                        }
-
-                        @Override
-                        public void onFetchFailed(
-                                final Conversation conversation, final String errorCondition) {
-                            if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
-                                Log.d(
-                                        Config.LOGTAG,
-                                        account.getJid().asBareJid()
-                                                + ": conversation ("
-                                                + conversation.getJid()
-                                                + ") got archived before IQ result");
-                                return;
-                            }
-                            if ("remote-server-not-found".equals(errorCondition)) {
-                                synchronized (account.inProgressConferenceJoins) {
-                                    account.inProgressConferenceJoins.remove(conversation);
-                                }
-                                conversation
-                                        .getMucOptions()
-                                        .setError(MucOptions.Error.SERVER_NOT_FOUND);
-                                updateConversationUi();
-                            } else {
-                                join(conversation);
-                                fetchConferenceConfiguration(conversation);
-                            }
-                        }
-                    });
-            updateConversationUi();
-        } else {
-            synchronized (account.pendingConferenceJoins) {
-                account.pendingConferenceJoins.add(conversation);
-            }
-            conversation.resetMucOptions();
-            conversation.setHasMessagesLeftOnServer(false);
-            updateConversationUi();
-        }
-    }
-
-    private void fetchConferenceMembers(final Conversation conversation) {
-        final Account account = conversation.getAccount();
-        final AxolotlService axolotlService = account.getAxolotlService();
-        final String[] affiliations = {"member", "admin", "owner"};
-        final Consumer<Iq> callback =
-                new Consumer<Iq>() {
-
-                    private int i = 0;
-                    private boolean success = true;
-
-                    @Override
-                    public void accept(Iq response) {
-                        final boolean omemoEnabled =
-                                conversation.getNextEncryption() == Message.ENCRYPTION_AXOLOTL;
-                        Element query = response.query("http://jabber.org/protocol/muc#admin");
-                        if (response.getType() == Iq.Type.RESULT && query != null) {
-                            for (Element child : query.getChildren()) {
-                                if ("item".equals(child.getName())) {
-                                    MucOptions.User user =
-                                            AbstractParser.parseItem(conversation, child);
-                                    if (!user.realJidMatchesAccount()) {
-                                        boolean isNew =
-                                                conversation.getMucOptions().updateUser(user);
-                                        Contact contact = user.getContact();
-                                        if (omemoEnabled
-                                                && isNew
-                                                && user.getRealJid() != null
-                                                && (contact == null
-                                                        || !contact.mutualPresenceSubscription())
-                                                && axolotlService.hasEmptyDeviceList(
-                                                        user.getRealJid())) {
-                                            axolotlService.fetchDeviceIds(user.getRealJid());
-                                        }
-                                    }
-                                }
-                            }
-                        } else {
-                            success = false;
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": could not request affiliation "
-                                            + affiliations[i]
-                                            + " in "
-                                            + conversation.getJid().asBareJid());
-                        }
-                        ++i;
-                        if (i >= affiliations.length) {
-                            final var mucOptions = conversation.getMucOptions();
-                            final var members = mucOptions.getMembers(true);
-                            if (success) {
-                                List<Jid> cryptoTargets = conversation.getAcceptedCryptoTargets();
-                                boolean changed = false;
-                                for (ListIterator<Jid> iterator = cryptoTargets.listIterator();
-                                        iterator.hasNext(); ) {
-                                    Jid jid = iterator.next();
-                                    if (!members.contains(jid)
-                                            && !members.contains(jid.getDomain())) {
-                                        iterator.remove();
-                                        Log.d(
-                                                Config.LOGTAG,
-                                                account.getJid().asBareJid()
-                                                        + ": removed "
-                                                        + jid
-                                                        + " from crypto targets of "
-                                                        + conversation.getName());
-                                        changed = true;
-                                    }
-                                }
-                                if (changed) {
-                                    conversation.setAcceptedCryptoTargets(cryptoTargets);
-                                    updateConversation(conversation);
-                                }
-                            }
-                            getAvatarService().clear(mucOptions);
-                            updateMucRosterUi();
-                            updateConversationUi();
-                        }
-                    }
-                };
-        for (String affiliation : affiliations) {
-            sendIqPacket(
-                    account, mIqGenerator.queryAffiliation(conversation, affiliation), callback);
-        }
-        Log.d(
-                Config.LOGTAG,
-                account.getJid().asBareJid() + ": fetching members for " + conversation.getName());
+    public void joinMuc(final Conversation conversation) {
+        final var account = conversation.getAccount();
+        account.getXmppConnection().getManager(MultiUserChatManager.class).join(conversation);
     }
 
     public void providePasswordForMuc(final Conversation conversation, final String password) {
-        if (conversation.getMode() == Conversation.MODE_MULTI) {
-            conversation.getMucOptions().setPassword(password);
-            if (conversation.getBookmark() != null) {
-                final Bookmark bookmark = conversation.getBookmark();
-                bookmark.setAutojoin(true);
-                createBookmark(conversation.getAccount(), bookmark);
-            }
-            updateConversation(conversation);
-            joinMuc(conversation);
-        }
+        final var account = conversation.getAccount();
+        account.getXmppConnection()
+                .getManager(MultiUserChatManager.class)
+                .setPassword(conversation, password);
     }
 
     public void deleteAvatar(final Account account) {
@@ -3476,23 +2999,28 @@ public class XmppConnectionService extends Service {
     }
 
     public void deletePepNode(final Account account, final String node) {
-        final Iq request = mIqGenerator.deleteNode(node);
-        sendIqPacket(
-                account,
-                request,
-                (packet) -> {
-                    if (packet.getType() == Iq.Type.RESULT) {
+        final var future = account.getXmppConnection().getManager(PepManager.class).delete(node);
+        Futures.addCallback(
+                future,
+                new FutureCallback<Void>() {
+                    @Override
+                    public void onSuccess(Void result) {
                         Log.d(
                                 Config.LOGTAG,
                                 account.getJid().asBareJid()
                                         + ": successfully deleted pep node "
                                         + node);
-                    } else {
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {
                         Log.d(
                                 Config.LOGTAG,
-                                account.getJid().asBareJid() + ": failed to delete " + packet);
+                                account.getJid().asBareJid() + ": failed to delete node " + node,
+                                t);
                     }
-                });
+                },
+                MoreExecutors.directExecutor());
     }
 
     private boolean hasEnabledAccounts() {
@@ -3565,154 +3093,17 @@ public class XmppConnectionService extends Service {
         createBookmark(bookmark.getAccount(), bookmark);
     }
 
-    public boolean renameInMuc(
-            final Conversation conversation,
-            final String nick,
-            final UiCallback<Conversation> callback) {
-        final Account account = conversation.getAccount();
-        final Bookmark bookmark = conversation.getBookmark();
-        final MucOptions options = conversation.getMucOptions();
-        final Jid joinJid = options.createJoinJid(nick);
-        if (joinJid == null) {
-            return false;
-        }
-        if (options.online()) {
-            options.setOnRenameListener(
-                    new OnRenameListener() {
-
-                        @Override
-                        public void onSuccess() {
-                            callback.success(conversation);
-                        }
-
-                        @Override
-                        public void onFailure() {
-                            callback.error(R.string.nick_in_use, conversation);
-                        }
-                    });
-
-            final var packet =
-                    mPresenceGenerator.selfPresence(
-                            account,
-                            im.conversations.android.xmpp.model.stanza.Presence.Availability.ONLINE,
-                            options.nonanonymous());
-            packet.setTo(joinJid);
-            sendPresencePacket(account, packet);
-            if (nick.equals(MucOptions.defaultNick(account))
-                    && bookmark != null
-                    && bookmark.getNick() != null) {
-                Log.d(
-                        Config.LOGTAG,
-                        account.getJid().asBareJid()
-                                + ": removing nick from bookmark for "
-                                + bookmark.getJid());
-                bookmark.setNick(null);
-                createBookmark(account, bookmark);
-            }
-        } else {
-            conversation.setContactJid(joinJid);
-            databaseBackend.updateConversation(conversation);
-            if (account.getStatus() == Account.State.ONLINE) {
-                if (bookmark != null) {
-                    bookmark.setNick(nick);
-                    createBookmark(account, bookmark);
-                }
-                joinMuc(conversation);
-            }
-        }
-        return true;
-    }
-
     public void checkMucRequiresRename() {
         synchronized (this.conversations) {
             for (final Conversation conversation : this.conversations) {
                 if (conversation.getMode() == Conversational.MODE_MULTI) {
-                    checkMucRequiresRename(conversation);
-                }
-            }
-        }
-    }
-
-    private void checkMucRequiresRename(final Conversation conversation) {
-        final var options = conversation.getMucOptions();
-        if (!options.online()) {
-            return;
-        }
-        final var account = conversation.getAccount();
-        final String current = options.getActualNick();
-        final String proposed = options.getProposedNickPure();
-        if (current == null || current.equals(proposed)) {
-            return;
-        }
-        final Jid joinJid = options.createJoinJid(proposed);
-        Log.d(
-                Config.LOGTAG,
-                String.format(
-                        "%s: muc rename required %s (was: %s)",
-                        account.getJid().asBareJid(), joinJid, current));
-        final var packet =
-                mPresenceGenerator.selfPresence(
-                        account,
-                        im.conversations.android.xmpp.model.stanza.Presence.Availability.ONLINE,
-                        options.nonanonymous());
-        packet.setTo(joinJid);
-        sendPresencePacket(account, packet);
-    }
-
-    public void leaveMuc(Conversation conversation) {
-        leaveMuc(conversation, false);
-    }
-
-    private void leaveMuc(Conversation conversation, boolean now) {
-        final Account account = conversation.getAccount();
-        synchronized (account.pendingConferenceJoins) {
-            account.pendingConferenceJoins.remove(conversation);
-        }
-        synchronized (account.pendingConferenceLeaves) {
-            account.pendingConferenceLeaves.remove(conversation);
-        }
-        if (account.getStatus() == Account.State.ONLINE || now) {
-            sendPresencePacket(
-                    conversation.getAccount(),
-                    mPresenceGenerator.leave(conversation.getMucOptions()));
-            conversation.getMucOptions().setOffline();
-            Bookmark bookmark = conversation.getBookmark();
-            if (bookmark != null) {
-                bookmark.setConversation(null);
-            }
-            Log.d(
-                    Config.LOGTAG,
-                    conversation.getAccount().getJid().asBareJid()
-                            + ": leaving muc "
-                            + conversation.getJid());
-            final var connection = account.getXmppConnection();
-            if (connection != null) {
-                connection.getManager(DiscoManager.class).clear(conversation.getJid().asBareJid());
-            }
-        } else {
-            synchronized (account.pendingConferenceLeaves) {
-                account.pendingConferenceLeaves.add(conversation);
-            }
-        }
-    }
-
-    public String findConferenceServer(final Account account) {
-        String server;
-        if (account.getXmppConnection() != null) {
-            server = account.getXmppConnection().getMucServer();
-            if (server != null) {
-                return server;
-            }
-        }
-        for (Account other : getAccounts()) {
-            if (other != account && other.getXmppConnection() != null) {
-                server = other.getXmppConnection().getMucServer();
-                if (server != null) {
-                    return server;
+                    final var account = conversation.getAccount();
+                    account.getXmppConnection()
+                            .getManager(MultiUserChatManager.class)
+                            .checkMucRequiresRename(conversation);
                 }
             }
         }
-        return null;
     }
 
     public void createPublicChannel(
@@ -3720,226 +3111,57 @@ public class XmppConnectionService extends Service {
             final String name,
             final Jid address,
             final UiCallback<Conversation> callback) {
-        joinMuc(
-                findOrCreateConversation(account, address, true, false, true),
-                conversation -> {
-                    final Bundle configuration = IqGenerator.defaultChannelConfiguration();
-                    if (!TextUtils.isEmpty(name)) {
-                        configuration.putString("muc#roomconfig_roomname", name);
+        final var future =
+                account.getXmppConnection()
+                        .getManager(MultiUserChatManager.class)
+                        .createPublicChannel(address, name);
+
+        Futures.addCallback(
+                future,
+                new FutureCallback<Conversation>() {
+                    @Override
+                    public void onSuccess(Conversation result) {
+                        callback.success(result);
                     }
-                    pushConferenceConfiguration(
-                            conversation,
-                            configuration,
-                            new OnConfigurationPushed() {
-                                @Override
-                                public void onPushSucceeded() {
-                                    saveConversationAsBookmark(conversation, name);
-                                    callback.success(conversation);
-                                }
 
-                                @Override
-                                public void onPushFailed() {
-                                    if (conversation
-                                            .getMucOptions()
-                                            .getSelf()
-                                            .getAffiliation()
-                                            .ranks(MucOptions.Affiliation.OWNER)) {
-                                        callback.error(
-                                                R.string.unable_to_set_channel_configuration,
-                                                conversation);
-                                    } else {
-                                        callback.error(
-                                                R.string.joined_an_existing_channel, conversation);
-                                    }
-                                }
-                            });
-                });
+                    @Override
+                    public void onFailure(Throwable t) {
+                        Log.d(Config.LOGTAG, "could not create public channel", t);
+                        // TODO I guess it’s better to just not use callbacks here
+                        callback.error(R.string.unable_to_set_channel_configuration, null);
+                    }
+                },
+                MoreExecutors.directExecutor());
     }
 
     public boolean createAdhocConference(
             final Account account,
             final String name,
-            final Iterable<Jid> jids,
+            final Collection<Jid> addresses,
             final UiCallback<Conversation> callback) {
-        Log.d(
-                Config.LOGTAG,
-                account.getJid().asBareJid().toString()
-                        + ": creating adhoc conference with "
-                        + jids.toString());
-        if (account.getStatus() == Account.State.ONLINE) {
-            try {
-                String server = findConferenceServer(account);
-                if (server == null) {
-                    if (callback != null) {
-                        callback.error(R.string.no_conference_server_found, null);
-                    }
-                    return false;
-                }
-                final Jid jid = Jid.of(CryptoHelper.pronounceable(), server, null);
-                final Conversation conversation =
-                        findOrCreateConversation(account, jid, true, false, true);
-                joinMuc(
-                        conversation,
-                        new OnConferenceJoined() {
-                            @Override
-                            public void onConferenceJoined(final Conversation conversation) {
-                                final Bundle configuration =
-                                        IqGenerator.defaultGroupChatConfiguration();
-                                if (!TextUtils.isEmpty(name)) {
-                                    configuration.putString("muc#roomconfig_roomname", name);
-                                }
-                                pushConferenceConfiguration(
-                                        conversation,
-                                        configuration,
-                                        new OnConfigurationPushed() {
-                                            @Override
-                                            public void onPushSucceeded() {
-                                                for (Jid invite : jids) {
-                                                    invite(conversation, invite);
-                                                }
-                                                for (String resource :
-                                                        account.getSelfContact()
-                                                                .getPresences()
-                                                                .toResourceArray()) {
-                                                    Jid other =
-                                                            account.getJid().withResource(resource);
-                                                    Log.d(
-                                                            Config.LOGTAG,
-                                                            account.getJid().asBareJid()
-                                                                    + ": sending direct invite to "
-                                                                    + other);
-                                                    directInvite(conversation, other);
-                                                }
-                                                saveConversationAsBookmark(conversation, name);
-                                                if (callback != null) {
-                                                    callback.success(conversation);
-                                                }
-                                            }
-
-                                            @Override
-                                            public void onPushFailed() {
-                                                archiveConversation(conversation);
-                                                if (callback != null) {
-                                                    callback.error(
-                                                            R.string.conference_creation_failed,
-                                                            conversation);
-                                                }
-                                            }
-                                        });
-                            }
-                        });
-                return true;
-            } catch (IllegalArgumentException e) {
-                if (callback != null) {
-                    callback.error(R.string.conference_creation_failed, null);
-                }
-                return false;
-            }
-        } else {
-            if (callback != null) {
-                callback.error(R.string.not_connected_try_again, null);
-            }
+        final var manager = account.getXmppConnection().getManager(MultiUserChatManager.class);
+        if (manager.getServices().isEmpty()) {
             return false;
         }
-    }
 
-    public void fetchConferenceConfiguration(final Conversation conversation) {
-        fetchConferenceConfiguration(conversation, null);
-    }
+        final var future = manager.createPrivateGroupChat(name, addresses);
 
-    public void fetchConferenceConfiguration(
-            final Conversation conversation, final OnConferenceConfigurationFetched callback) {
-        final var account = conversation.getAccount();
-        final var connection = account.getXmppConnection();
-        final var address = conversation.getJid().asBareJid();
-        if (connection == null) {
-            return;
-        }
-        final var future =
-                connection.getManager(DiscoManager.class).info(Entity.discoItem(address), null);
         Futures.addCallback(
                 future,
                 new FutureCallback<>() {
                     @Override
-                    public void onSuccess(InfoQuery result) {
-                        final var avatarHash =
-                                result.getServiceDiscoveryExtension(
-                                        Namespace.MUC_ROOM_INFO, "muc#roominfo_avatarhash");
-                        if (VCardUpdate.isValidSHA1(avatarHash)) {
-                            connection
-                                    .getManager(AvatarManager.class)
-                                    .handleVCardUpdate(address, avatarHash);
-                        }
-                        final MucOptions mucOptions = conversation.getMucOptions();
-                        final Bookmark bookmark = conversation.getBookmark();
-                        final boolean sameBefore =
-                                StringUtils.equals(
-                                        bookmark == null ? null : bookmark.getBookmarkName(),
-                                        mucOptions.getName());
-
-                        final var hadOccupantId = mucOptions.occupantId();
-                        if (mucOptions.updateConfiguration(result)) {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": muc configuration changed for "
-                                            + conversation.getJid().asBareJid());
-                            updateConversation(conversation);
-                        }
-
-                        final var hasOccupantId = mucOptions.occupantId();
-
-                        if (!hadOccupantId && hasOccupantId && mucOptions.online()) {
-                            final var me = mucOptions.getSelf().getFullJid();
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": gained support for occupant-id in "
-                                            + me
-                                            + ". resending presence");
-                            final var packet =
-                                    mPresenceGenerator.selfPresence(
-                                            account,
-                                            im.conversations.android.xmpp.model.stanza.Presence
-                                                    .Availability.ONLINE,
-                                            mucOptions.nonanonymous());
-                            packet.setTo(me);
-                            sendPresencePacket(account, packet);
-                        }
-
-                        if (bookmark != null
-                                && (sameBefore || bookmark.getBookmarkName() == null)) {
-                            if (bookmark.setBookmarkName(
-                                    StringUtils.nullOnEmpty(mucOptions.getName()))) {
-                                createBookmark(account, bookmark);
-                            }
-                        }
-
-                        if (callback != null) {
-                            callback.onConferenceConfigurationFetched(conversation);
-                        }
-
-                        updateConversationUi();
+                    public void onSuccess(Conversation result) {
+                        callback.success(result);
                     }
 
                     @Override
-                    public void onFailure(@NonNull Throwable throwable) {
-                        if (throwable instanceof TimeoutException) {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": received timeout waiting for conference"
-                                            + " configuration fetch");
-                        } else if (throwable instanceof IqErrorException errorResponseException) {
-                            if (callback != null) {
-                                callback.onFetchFailed(
-                                        conversation,
-                                        errorResponseException.getResponse().getErrorCondition());
-                            }
-                        }
+                    public void onFailure(@NonNull Throwable t) {
+                        Log.d(Config.LOGTAG, "could not create private group chat", t);
+                        callback.error(R.string.conference_creation_failed, null);
                     }
                 },
                 MoreExecutors.directExecutor());
+        return true;
     }
 
     public void pushNodeConfiguration(

src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java πŸ”—

@@ -35,6 +35,7 @@ import eu.siacs.conversations.ui.util.PendingItem;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.manager.BookmarkManager;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
@@ -299,7 +300,9 @@ public class ChannelDiscoveryActivity extends XmppActivity
         final Conversation conversation =
                 xmppConnectionService.findOrCreateConversation(
                         account, result.getRoom(), true, true, true);
-        xmppConnectionService.ensureBookmarkIsAutoJoin(conversation);
+        account.getXmppConnection()
+                .getManager(BookmarkManager.class)
+                .ensureBookmarkIsAutoJoin(conversation);
         switchToConversation(conversation);
     }
 }

src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java πŸ”—

@@ -4,7 +4,6 @@ import static eu.siacs.conversations.entities.Bookmark.printableValue;
 import static eu.siacs.conversations.utils.StringUtils.changed;
 
 import android.app.Activity;
-import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Build;
@@ -19,9 +18,15 @@ import android.view.View;
 import android.view.View.OnClickListener;
 import android.widget.Toast;
 import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
 import androidx.appcompat.app.AlertDialog;
+import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
 import de.gultsch.common.Linkify;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityMucDetailsBinding;
@@ -45,10 +50,13 @@ import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.utils.Compatibility;
-import eu.siacs.conversations.utils.StringUtils;
 import eu.siacs.conversations.utils.StylingHelper;
 import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.manager.BookmarkManager;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.Role;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -58,8 +66,6 @@ public class ConferenceDetailsActivity extends XmppActivity
         implements OnConversationUpdate,
                 OnMucRosterUpdate,
                 XmppConnectionService.OnAffiliationChanged,
-                XmppConnectionService.OnConfigurationPushed,
-                XmppConnectionService.OnRoomDestroy,
                 TextWatcher,
                 OnMediaLoaded {
     public static final String ACTION_VIEW_MUC = "view_muc";
@@ -72,24 +78,20 @@ public class ConferenceDetailsActivity extends XmppActivity
 
     private boolean mAdvancedMode = false;
 
-    private final UiCallback<Conversation> renameCallback =
-            new UiCallback<Conversation>() {
+    private FutureCallback<Void> renameCallback =
+            new FutureCallback<Void>() {
                 @Override
-                public void success(Conversation object) {
+                public void onSuccess(Void result) {
                     displayToast(getString(R.string.your_nick_has_been_changed));
-                    runOnUiThread(
-                            () -> {
-                                updateView();
-                            });
+                    updateView();
                 }
 
                 @Override
-                public void error(final int errorCode, Conversation object) {
-                    displayToast(getString(errorCode));
-                }
+                public void onFailure(Throwable t) {
 
-                @Override
-                public void userInputRequired(PendingIntent pi, Conversation object) {}
+                    // TODO check for NickInUseException and NickInvalid exception
+
+                }
             };
 
     public static void open(final Activity activity, final Conversation conversation) {
@@ -139,6 +141,20 @@ public class ConferenceDetailsActivity extends XmppActivity
                 }
             };
 
+    private final FutureCallback<Void> onConfigurationPushed =
+            new FutureCallback<Void>() {
+
+                @Override
+                public void onSuccess(Void result) {
+                    displayToast(getString(R.string.modified_conference_options));
+                }
+
+                @Override
+                public void onFailure(Throwable t) {
+                    displayToast(getString(R.string.could_not_modify_conference_options));
+                }
+            };
+
     private final OnClickListener mChangeConferenceSettings =
             new OnClickListener() {
                 @Override
@@ -159,15 +175,17 @@ public class ConferenceDetailsActivity extends XmppActivity
                     builder.setPositiveButton(
                             R.string.confirm,
                             (dialog, which) -> {
-                                final Bundle options = configuration.toBundle(values);
-                                options.putString("muc#roomconfig_persistentroom", "1");
-                                if (options.containsKey("muc#roomconfig_allowinvites")) {
-                                    options.putString(
-                                            "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites",
-                                            options.getString("muc#roomconfig_allowinvites"));
-                                }
-                                xmppConnectionService.pushConferenceConfiguration(
-                                        mConversation, options, ConferenceDetailsActivity.this);
+                                final var options = configuration.toBundle(values);
+                                final var future =
+                                        mConversation
+                                                .getAccount()
+                                                .getXmppConnection()
+                                                .getManager(MultiUserChatManager.class)
+                                                .pushConfiguration(mConversation, options);
+                                Futures.addCallback(
+                                        future,
+                                        onConfigurationPushed,
+                                        ContextCompat.getMainExecutor(getApplication()));
                             });
                     builder.create().show();
                 }
@@ -202,12 +220,21 @@ public class ConferenceDetailsActivity extends XmppActivity
                                 mConversation.getMucOptions().getActualNick(),
                                 R.string.nickname,
                                 value -> {
-                                    if (xmppConnectionService.renameInMuc(
-                                            mConversation, value, renameCallback)) {
-                                        return null;
-                                    } else {
+                                    if (mConversation.getMucOptions().createJoinJid(value)
+                                            == null) {
                                         return getString(R.string.invalid_muc_nick);
                                     }
+                                    final var future =
+                                            mConversation
+                                                    .getAccount()
+                                                    .getXmppConnection()
+                                                    .getManager(MultiUserChatManager.class)
+                                                    .changeUsername(mConversation, value);
+                                    Futures.addCallback(
+                                            future,
+                                            renameCallback,
+                                            ContextCompat.getMainExecutor(this));
+                                    return null;
                                 }));
         this.mAdvancedMode = getPreferences().getBoolean("advanced_muc_mode", false);
         this.binding.mucInfoMore.setVisibility(this.mAdvancedMode ? View.VISIBLE : View.GONE);
@@ -223,10 +250,7 @@ public class ConferenceDetailsActivity extends XmppActivity
                                 .show();
                         return;
                     }
-                    if (!mucOptions
-                            .getSelf()
-                            .getAffiliation()
-                            .ranks(MucOptions.Affiliation.OWNER)) {
+                    if (!mucOptions.getSelf().ranks(Affiliation.OWNER)) {
                         Toast.makeText(
                                         this,
                                         R.string.only_the_owner_can_change_group_chat_avatar,
@@ -344,8 +368,7 @@ public class ConferenceDetailsActivity extends XmppActivity
             this.binding.editMucNameButton.setContentDescription(getString(R.string.cancel));
             final String name = mucOptions.getName();
             this.binding.mucEditTitle.setText("");
-            final boolean owner =
-                    mucOptions.getSelf().getAffiliation().ranks(MucOptions.Affiliation.OWNER);
+            final boolean owner = mucOptions.getSelf().ranks(Affiliation.OWNER);
             if (owner || printableValue(name)) {
                 this.binding.mucEditTitle.setVisibility(View.VISIBLE);
                 if (name != null) {
@@ -388,16 +411,23 @@ public class ConferenceDetailsActivity extends XmppActivity
     }
 
     private void onMucInfoUpdated(String subject, String name) {
+        final var account = mConversation.getAccount();
         final MucOptions mucOptions = mConversation.getMucOptions();
         if (mucOptions.canChangeSubject() && changed(mucOptions.getSubject(), subject)) {
             xmppConnectionService.pushSubjectToConference(mConversation, subject);
         }
-        if (mucOptions.getSelf().getAffiliation().ranks(MucOptions.Affiliation.OWNER)
-                && changed(mucOptions.getName(), name)) {
-            Bundle options = new Bundle();
-            options.putString("muc#roomconfig_persistentroom", "1");
-            options.putString("muc#roomconfig_roomname", StringUtils.nullOnEmpty(name));
-            xmppConnectionService.pushConferenceConfiguration(mConversation, options, this);
+        if (mucOptions.getSelf().ranks(Affiliation.OWNER) && changed(mucOptions.getName(), name)) {
+            final var options =
+                    new ImmutableMap.Builder<String, Object>()
+                            .put("muc#roomconfig_persistentroom", true)
+                            .put("muc#roomconfig_roomname", Strings.nullToEmpty(name))
+                            .build();
+            final var future =
+                    account.getXmppConnection()
+                            .getManager(MultiUserChatManager.class)
+                            .pushConfiguration(mConversation, options);
+            Futures.addCallback(
+                    future, onConfigurationPushed, ContextCompat.getMainExecutor(getApplication()));
         }
     }
 
@@ -426,11 +456,7 @@ public class ConferenceDetailsActivity extends XmppActivity
         }
         menuItemSaveBookmark.setVisible(mConversation.getBookmark() == null);
         menuItemDestroyRoom.setVisible(
-                mConversation
-                        .getMucOptions()
-                        .getSelf()
-                        .getAffiliation()
-                        .ranks(MucOptions.Affiliation.OWNER));
+                mConversation.getMucOptions().getSelf().ranks(Affiliation.OWNER));
         return true;
     }
 
@@ -461,11 +487,33 @@ public class ConferenceDetailsActivity extends XmppActivity
     }
 
     protected void saveAsBookmark() {
-        xmppConnectionService.saveConversationAsBookmark(
-                mConversation, mConversation.getMucOptions().getName());
+        final var account = mConversation.getAccount();
+        account.getXmppConnection()
+                .getManager(BookmarkManager.class)
+                .save(mConversation, mConversation.getMucOptions().getName());
     }
 
     protected void destroyRoom() {
+        final var destroyCallBack =
+                new FutureCallback<Void>() {
+
+                    @Override
+                    public void onSuccess(Void result) {
+                        finish();
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        final boolean groupChat =
+                                mConversation != null && mConversation.isPrivateAndNonAnonymous();
+                        // TODO show toast directly
+                        displayToast(
+                                getString(
+                                        groupChat
+                                                ? R.string.could_not_destroy_room
+                                                : R.string.could_not_destroy_channel));
+                    }
+                };
         final boolean groupChat = mConversation != null && mConversation.isPrivateAndNonAnonymous();
         final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
         builder.setTitle(groupChat ? R.string.destroy_room : R.string.destroy_channel);
@@ -474,8 +522,11 @@ public class ConferenceDetailsActivity extends XmppActivity
         builder.setPositiveButton(
                 R.string.ok,
                 (dialog, which) -> {
-                    xmppConnectionService.destroyRoom(
-                            mConversation, ConferenceDetailsActivity.this);
+                    final var future = xmppConnectionService.destroyRoom(mConversation);
+                    Futures.addCallback(
+                            future,
+                            destroyCallBack,
+                            ContextCompat.getMainExecutor(getApplication()));
                 });
         builder.setNegativeButton(R.string.cancel, null);
         final AlertDialog dialog = builder.create();
@@ -528,8 +579,7 @@ public class ConferenceDetailsActivity extends XmppActivity
                         ? R.string.action_muc_details
                         : R.string.channel_details);
         this.binding.editMucNameButton.setVisibility(
-                (self.getAffiliation().ranks(MucOptions.Affiliation.OWNER)
-                                || mucOptions.canChangeSubject())
+                (self.ranks(Affiliation.OWNER) || mucOptions.canChangeSubject())
                         ? View.VISIBLE
                         : View.GONE);
         this.binding.detailsAccount.setText(getString(R.string.using_account, account));
@@ -579,7 +629,7 @@ public class ConferenceDetailsActivity extends XmppActivity
             this.binding.mucInfoMore.setVisibility(this.mAdvancedMode ? View.VISIBLE : View.GONE);
             this.binding.mucRole.setVisibility(View.VISIBLE);
             this.binding.mucRole.setText(getStatus(self));
-            if (mucOptions.getSelf().getAffiliation().ranks(MucOptions.Affiliation.OWNER)) {
+            if (mucOptions.getSelf().ranks(Affiliation.OWNER)) {
                 this.binding.mucSettings.setVisibility(View.VISIBLE);
                 this.binding.mucConferenceType.setText(MucConfiguration.describe(this, mucOptions));
             } else if (!mucOptions.isPrivateAndNonAnonymous() && mucOptions.nonanonymous()) {
@@ -594,7 +644,7 @@ public class ConferenceDetailsActivity extends XmppActivity
             } else {
                 this.binding.mucInfoMam.setText(R.string.server_info_unavailable);
             }
-            if (self.getAffiliation().ranks(MucOptions.Affiliation.OWNER)) {
+            if (self.ranks(Affiliation.OWNER)) {
                 this.binding.changeConferenceButton.setVisibility(View.VISIBLE);
             } else {
                 this.binding.changeConferenceButton.setVisibility(View.INVISIBLE);
@@ -627,9 +677,9 @@ public class ConferenceDetailsActivity extends XmppActivity
         Collections.sort(
                 users,
                 (a, b) -> {
-                    if (b.getAffiliation().outranks(a.getAffiliation())) {
+                    if (b.outranks(a.getAffiliation())) {
                         return 1;
-                    } else if (a.getAffiliation().outranks(b.getAffiliation())) {
+                    } else if (a.outranks(b.getAffiliation())) {
                         return -1;
                     } else {
                         if (a.getAvatar() != null && b.getAvatar() == null) {
@@ -668,13 +718,32 @@ public class ConferenceDetailsActivity extends XmppActivity
         if (advanced) {
             return String.format(
                     "%s (%s)",
-                    context.getString(user.getAffiliation().getResId()),
-                    context.getString(user.getRole().getResId()));
+                    context.getString(affiliationToStringRes(user.getAffiliation())),
+                    context.getString(roleToStringRes(user.getRole())));
         } else {
-            return context.getString(user.getAffiliation().getResId());
+            return context.getString(affiliationToStringRes(user.getAffiliation()));
         }
     }
 
+    private static @StringRes int affiliationToStringRes(final Affiliation affiliation) {
+        return switch (affiliation) {
+            case OWNER -> R.string.owner;
+            case ADMIN -> R.string.admin;
+            case MEMBER -> R.string.member;
+            case NONE -> R.string.no_affiliation;
+            case OUTCAST -> R.string.outcast;
+        };
+    }
+
+    private static @StringRes int roleToStringRes(final Role role) {
+        return switch (role) {
+            case MODERATOR -> R.string.moderator;
+            case VISITOR -> R.string.visitor;
+            case PARTICIPANT -> R.string.participant;
+            case NONE -> R.string.no_role;
+        };
+    }
+
     private String getStatus(User user) {
         return getStatus(this, user, mAdvancedMode);
     }
@@ -689,31 +758,6 @@ public class ConferenceDetailsActivity extends XmppActivity
         displayToast(getString(resId, jid.asBareJid().toString()));
     }
 
-    @Override
-    public void onRoomDestroySucceeded() {
-        finish();
-    }
-
-    @Override
-    public void onRoomDestroyFailed() {
-        final boolean groupChat = mConversation != null && mConversation.isPrivateAndNonAnonymous();
-        displayToast(
-                getString(
-                        groupChat
-                                ? R.string.could_not_destroy_room
-                                : R.string.could_not_destroy_channel));
-    }
-
-    @Override
-    public void onPushSucceeded() {
-        displayToast(getString(R.string.modified_conference_options));
-    }
-
-    @Override
-    public void onPushFailed() {
-        displayToast(getString(R.string.could_not_modify_conference_options));
-    }
-
     private void displayToast(final String msg) {
         runOnUiThread(
                 () -> {

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java πŸ”—

@@ -128,6 +128,7 @@ import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
 import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
@@ -1178,9 +1179,9 @@ public class ConversationFragment extends XmppFragment
                 }
                 menuContactDetails.setVisible(!this.conversation.withSelf());
                 menuMucDetails.setVisible(false);
+                final var connection = this.conversation.getAccount().getXmppConnection();
                 menuInviteContact.setVisible(
-                        service != null
-                                && service.findConferenceServer(conversation.getAccount()) != null);
+                        !connection.getManager(MultiUserChatManager.class).getServices().isEmpty());
             }
             if (conversation.isMuted()) {
                 menuMute.setVisible(false);

src/main/java/eu/siacs/conversations/ui/CreatePublicChannelDialog.java πŸ”—

@@ -26,6 +26,7 @@ import eu.siacs.conversations.ui.util.DelayedHintHelper;
 import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -158,7 +159,7 @@ public class CreatePublicChannelDialog extends DialogFragment implements OnBacke
         }
         final Editable nameText = binding.groupChatName.getText();
         final String name = nameText == null ? "" : nameText.toString().trim();
-        final String domain = connection.getMucServer();
+        final var domain = connection.getManager(MultiUserChatManager.class).getService();
         if (domain == null) {
             return "";
         }
@@ -270,9 +271,8 @@ public class CreatePublicChannelDialog extends DialogFragment implements OnBacke
 
     private void refreshKnownHosts() {
         Activity activity = getActivity();
-        if (activity instanceof XmppActivity) {
-            Collection<String> hosts =
-                    ((XmppActivity) activity).xmppConnectionService.getKnownConferenceHosts();
+        if (activity instanceof XmppActivity xmppActivity) {
+            Collection<String> hosts = xmppActivity.xmppConnectionService.getKnownConferenceHosts();
             this.knownHostsAdapter.refresh(hosts);
         }
     }

src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java πŸ”—

@@ -7,17 +7,12 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Toast;
-
 import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.ListAdapter;
 import androidx.recyclerview.widget.RecyclerView;
-
 import com.google.common.base.Strings;
-
-import org.openintents.openpgp.util.OpenPgpUtils;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.PgpEngine;
 import eu.siacs.conversations.databinding.ItemContactBinding;
@@ -30,30 +25,36 @@ import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
 import eu.siacs.conversations.utils.Compatibility;
 import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.muc.Role;
+import org.openintents.openpgp.util.OpenPgpUtils;
 
-public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHolder> implements View.OnCreateContextMenuListener {
-
-    static final DiffUtil.ItemCallback<MucOptions.User> DIFF = new DiffUtil.ItemCallback<MucOptions.User>() {
-        @Override
-        public boolean areItemsTheSame(@NonNull MucOptions.User a, @NonNull MucOptions.User b) {
-            final Jid fullA = a.getFullJid();
-            final Jid fullB = b.getFullJid();
-            final Jid realA = a.getRealJid();
-            final Jid realB = b.getRealJid();
-            if (fullA != null && fullB != null) {
-                return fullA.equals(fullB);
-            } else if (realA != null && realB != null) {
-                return realA.equals(realB);
-            } else {
-                return false;
-            }
-        }
+public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHolder>
+        implements View.OnCreateContextMenuListener {
+
+    static final DiffUtil.ItemCallback<MucOptions.User> DIFF =
+            new DiffUtil.ItemCallback<MucOptions.User>() {
+                @Override
+                public boolean areItemsTheSame(
+                        @NonNull MucOptions.User a, @NonNull MucOptions.User b) {
+                    final Jid fullA = a.getFullJid();
+                    final Jid fullB = b.getFullJid();
+                    final Jid realA = a.getRealJid();
+                    final Jid realB = b.getRealJid();
+                    if (fullA != null && fullB != null) {
+                        return fullA.equals(fullB);
+                    } else if (realA != null && realB != null) {
+                        return realA.equals(realB);
+                    } else {
+                        return false;
+                    }
+                }
 
-        @Override
-        public boolean areContentsTheSame(@NonNull MucOptions.User a, @NonNull MucOptions.User b) {
-            return a.equals(b);
-        }
-    };
+                @Override
+                public boolean areContentsTheSame(
+                        @NonNull MucOptions.User a, @NonNull MucOptions.User b) {
+                    return a.equals(b);
+                }
+            };
     private final boolean advancedMode;
     private MucOptions.User selectedUser = null;
 
@@ -65,73 +66,104 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
     @NonNull
     @Override
     public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int position) {
-        return new ViewHolder(DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), R.layout.item_contact, viewGroup, false));
+        return new ViewHolder(
+                DataBindingUtil.inflate(
+                        LayoutInflater.from(viewGroup.getContext()),
+                        R.layout.item_contact,
+                        viewGroup,
+                        false));
     }
 
     @Override
     public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
         final MucOptions.User user = getItem(position);
         AvatarWorkerTask.loadAvatar(user, viewHolder.binding.contactPhoto, R.dimen.avatar);
-        viewHolder.binding.getRoot().setOnClickListener(v -> {
-            final XmppActivity activity = XmppActivity.find(v);
-            if (activity == null) {
-                return;
-            }
-            final var contact = user.getContact();
-            if (user.getRole() == MucOptions.Role.NONE && contact != null) {
-                Toast.makeText(
-                                activity,
-                                activity.getString(
-                                        R.string.user_has_left_conference,
-                                        contact.getDisplayName()),
-                                Toast.LENGTH_SHORT)
-                        .show();
-            }
-            activity.highlightInMuc(user.getConversation(), user.getName());
-        });
+        viewHolder
+                .binding
+                .getRoot()
+                .setOnClickListener(
+                        v -> {
+                            final XmppActivity activity = XmppActivity.find(v);
+                            if (activity == null) {
+                                return;
+                            }
+                            final var contact = user.getContact();
+                            if (user.getRole() == Role.NONE && contact != null) {
+                                Toast.makeText(
+                                                activity,
+                                                activity.getString(
+                                                        R.string.user_has_left_conference,
+                                                        contact.getDisplayName()),
+                                                Toast.LENGTH_SHORT)
+                                        .show();
+                            }
+                            activity.highlightInMuc(user.getConversation(), user.getName());
+                        });
         viewHolder.binding.getRoot().setTag(user);
         viewHolder.binding.getRoot().setOnCreateContextMenuListener(this);
-        viewHolder.binding.getRoot().setOnLongClickListener(v -> {
-            selectedUser = user;
-            return false;
-        });
+        viewHolder
+                .binding
+                .getRoot()
+                .setOnLongClickListener(
+                        v -> {
+                            selectedUser = user;
+                            return false;
+                        });
         final String name = user.getName();
         final Contact contact = user.getContact();
         if (contact != null) {
             final String displayName = contact.getDisplayName();
             viewHolder.binding.contactDisplayName.setText(displayName);
             if (name != null && !name.equals(displayName)) {
-                viewHolder.binding.contactJid.setText(String.format("%s \u2022 %s", name, ConferenceDetailsActivity.getStatus(viewHolder.binding.getRoot().getContext(), user, advancedMode)));
+                viewHolder.binding.contactJid.setText(
+                        String.format(
+                                "%s \u2022 %s",
+                                name,
+                                ConferenceDetailsActivity.getStatus(
+                                        viewHolder.binding.getRoot().getContext(),
+                                        user,
+                                        advancedMode)));
             } else {
-                viewHolder.binding.contactJid.setText(ConferenceDetailsActivity.getStatus(viewHolder.binding.getRoot().getContext(), user, advancedMode));
+                viewHolder.binding.contactJid.setText(
+                        ConferenceDetailsActivity.getStatus(
+                                viewHolder.binding.getRoot().getContext(), user, advancedMode));
             }
         } else {
             viewHolder.binding.contactDisplayName.setText(Strings.nullToEmpty(name));
-            viewHolder.binding.contactJid.setText(ConferenceDetailsActivity.getStatus(viewHolder.binding.getRoot().getContext(), user, advancedMode));
+            viewHolder.binding.contactJid.setText(
+                    ConferenceDetailsActivity.getStatus(
+                            viewHolder.binding.getRoot().getContext(), user, advancedMode));
         }
         if (advancedMode && user.getPgpKeyId() != 0) {
             viewHolder.binding.key.setVisibility(View.VISIBLE);
-            viewHolder.binding.key.setOnClickListener(v -> {
-                final XmppActivity activity = XmppActivity.find(v);
-                final XmppConnectionService service = activity == null ? null : activity.xmppConnectionService;
-                final PgpEngine pgpEngine = service == null ? null : service.getPgpEngine();
-                if (pgpEngine != null) {
-                    PendingIntent intent = pgpEngine.getIntentForKey(user.getPgpKeyId());
-                    if (intent != null) {
-                        try {
-                            activity.startIntentSenderForResult(intent.getIntentSender(), 0, null, 0, 0, 0, Compatibility.pgpStartIntentSenderOptions());
-                        } catch (IntentSender.SendIntentException ignored) {
-
+            viewHolder.binding.key.setOnClickListener(
+                    v -> {
+                        final XmppActivity activity = XmppActivity.find(v);
+                        final XmppConnectionService service =
+                                activity == null ? null : activity.xmppConnectionService;
+                        final PgpEngine pgpEngine = service == null ? null : service.getPgpEngine();
+                        if (pgpEngine != null) {
+                            PendingIntent intent = pgpEngine.getIntentForKey(user.getPgpKeyId());
+                            if (intent != null) {
+                                try {
+                                    activity.startIntentSenderForResult(
+                                            intent.getIntentSender(),
+                                            0,
+                                            null,
+                                            0,
+                                            0,
+                                            0,
+                                            Compatibility.pgpStartIntentSenderOptions());
+                                } catch (IntentSender.SendIntentException ignored) {
+
+                                }
+                            }
                         }
-                    }
-                }
-            });
+                    });
             viewHolder.binding.key.setText(OpenPgpUtils.convertKeyIdToHex(user.getPgpKeyId()));
         } else {
             viewHolder.binding.key.setVisibility(View.GONE);
         }
-
-
     }
 
     public MucOptions.User getSelectedUser() {
@@ -139,8 +171,9 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
     }
 
     @Override
-    public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
-        MucDetailsContextMenuHelper.onCreateContextMenu(menu,v);
+    public void onCreateContextMenu(
+            ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
+        MucDetailsContextMenuHelper.onCreateContextMenu(menu, v);
     }
 
     static class ViewHolder extends RecyclerView.ViewHolder {

src/main/java/eu/siacs/conversations/ui/adapter/UserPreviewAdapter.java πŸ”—

@@ -5,18 +5,17 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Toast;
-
 import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 import androidx.recyclerview.widget.ListAdapter;
 import androidx.recyclerview.widget.RecyclerView;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ItemUserPreviewBinding;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.ui.XmppActivity;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
+import im.conversations.android.xmpp.model.muc.Role;
 
 public class UserPreviewAdapter extends ListAdapter<MucOptions.User, UserPreviewAdapter.ViewHolder>
         implements View.OnCreateContextMenuListener {
@@ -52,7 +51,7 @@ public class UserPreviewAdapter extends ListAdapter<MucOptions.User, UserPreview
                                 return;
                             }
                             final var contact = user.getContact();
-                            if (user.getRole() == MucOptions.Role.NONE && contact != null) {
+                            if (user.getRole() == Role.NONE && contact != null) {
                                 Toast.makeText(
                                                 activity,
                                                 activity.getString(

src/main/java/eu/siacs/conversations/ui/util/MucConfiguration.java πŸ”—

@@ -1,12 +1,11 @@
 package eu.siacs.conversations.ui.util;
 
 import android.content.Context;
-import android.os.Bundle;
-
 import androidx.annotation.StringRes;
-
+import com.google.common.collect.ImmutableMap;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.MucOptions;
+import java.util.Map;
 
 public class MucConfiguration {
 
@@ -116,22 +115,23 @@ public class MucConfiguration {
         return builder.toString();
     }
 
-    public Bundle toBundle(boolean[] values) {
-        Bundle bundle = new Bundle();
+    public Map<String, Object> toBundle(boolean[] values) {
+        final var builder = new ImmutableMap.Builder<String, Object>();
         for (int i = 0; i < values.length; ++i) {
             final Option option = options[i];
-            bundle.putString(option.name, option.values[values[i] ? 0 : 1]);
+            builder.put(option.name, option.values[values[i] ? 0 : 1]);
         }
-        return bundle;
+        builder.put("muc#roomconfig_persistentroom", true);
+        return builder.buildOrThrow();
     }
 
     private static class Option {
         public final String name;
-        public final String[] values;
+        public final Object[] values;
 
         private Option(String name) {
             this.name = name;
-            this.values = new String[] {"1", "0"};
+            this.values = new Boolean[] {true, false};
         }
 
         private Option(String name, String on, String off) {

src/main/java/eu/siacs/conversations/ui/util/MucDetailsContextMenuHelper.java πŸ”—

@@ -24,6 +24,8 @@ import eu.siacs.conversations.ui.ConversationsActivity;
 import eu.siacs.conversations.ui.MucUsersActivity;
 import eu.siacs.conversations.ui.XmppActivity;
 import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.Role;
 
 public final class MucDetailsContextMenuHelper {
 
@@ -81,18 +83,17 @@ public final class MucDetailsContextMenuHelper {
             }
             if ((activity instanceof ConferenceDetailsActivity
                             || activity instanceof MucUsersActivity)
-                    && user.getRole() == MucOptions.Role.NONE) {
+                    && user.getRole() == Role.NONE) {
                 invite.setVisible(true);
             }
             boolean managePermissionsVisible = false;
-            if ((self.getAffiliation().ranks(MucOptions.Affiliation.ADMIN)
-                            && self.getAffiliation().outranks(user.getAffiliation()))
-                    || self.getAffiliation() == MucOptions.Affiliation.OWNER) {
+            if ((self.ranks(Affiliation.ADMIN) && self.outranks(user.getAffiliation()))
+                    || self.getAffiliation() == Affiliation.OWNER) {
                 if (advancedMode) {
-                    if (!user.getAffiliation().ranks(MucOptions.Affiliation.MEMBER)) {
+                    if (!user.ranks(Affiliation.MEMBER)) {
                         managePermissionsVisible = true;
                         giveMembership.setVisible(true);
-                    } else if (user.getAffiliation() == MucOptions.Affiliation.MEMBER) {
+                    } else if (user.getAffiliation() == Affiliation.MEMBER) {
                         managePermissionsVisible = true;
                         removeMembership.setVisible(true);
                     }
@@ -106,25 +107,21 @@ public final class MucDetailsContextMenuHelper {
                     }
                 }
             }
-            if (self.getAffiliation().ranks(MucOptions.Affiliation.OWNER)) {
-                if (isGroupChat
-                        || advancedMode
-                        || user.getAffiliation() == MucOptions.Affiliation.OWNER) {
-                    if (!user.getAffiliation().ranks(MucOptions.Affiliation.OWNER)) {
+            if (self.ranks(Affiliation.OWNER)) {
+                if (isGroupChat || advancedMode || user.getAffiliation() == Affiliation.OWNER) {
+                    if (!user.ranks(Affiliation.OWNER)) {
                         managePermissionsVisible = true;
                         giveOwnerPrivileges.setVisible(true);
-                    } else if (user.getAffiliation() == MucOptions.Affiliation.OWNER) {
+                    } else if (user.getAffiliation() == Affiliation.OWNER) {
                         managePermissionsVisible = true;
                         removeOwnerPrivileges.setVisible(true);
                     }
                 }
-                if (!isGroupChat
-                        || advancedMode
-                        || user.getAffiliation() == MucOptions.Affiliation.ADMIN) {
-                    if (!user.getAffiliation().ranks(MucOptions.Affiliation.ADMIN)) {
+                if (!isGroupChat || advancedMode || user.getAffiliation() == Affiliation.ADMIN) {
+                    if (!user.ranks(Affiliation.ADMIN)) {
                         managePermissionsVisible = true;
                         giveAdminPrivileges.setVisible(true);
-                    } else if (user.getAffiliation() == MucOptions.Affiliation.ADMIN) {
+                    } else if (user.getAffiliation() == Affiliation.ADMIN) {
                         managePermissionsVisible = true;
                         removeAdminPrivileges.setVisible(true);
                     }
@@ -132,15 +129,11 @@ public final class MucDetailsContextMenuHelper {
             }
             managePermissions.setVisible(managePermissionsVisible);
             sendPrivateMessage.setVisible(
-                    !isGroupChat
-                            && mucOptions.allowPm()
-                            && user.getRole().ranks(MucOptions.Role.VISITOR));
+                    !isGroupChat && mucOptions.allowPm() && user.ranks(Role.VISITOR));
         } else {
             sendPrivateMessage.setVisible(true);
             sendPrivateMessage.setEnabled(
-                    user != null
-                            && mucOptions.allowPm()
-                            && user.getRole().ranks(MucOptions.Role.VISITOR));
+                    user != null && mucOptions.allowPm() && user.ranks(Role.VISITOR));
         }
     }
 
@@ -171,31 +164,31 @@ public final class MucDetailsContextMenuHelper {
                 return true;
             case R.id.give_admin_privileges:
                 activity.xmppConnectionService.changeAffiliationInConference(
-                        conversation, jid, MucOptions.Affiliation.ADMIN, onAffiliationChanged);
+                        conversation, jid, Affiliation.ADMIN, onAffiliationChanged);
                 return true;
             case R.id.give_membership:
             case R.id.remove_admin_privileges:
             case R.id.revoke_owner_privileges:
                 activity.xmppConnectionService.changeAffiliationInConference(
-                        conversation, jid, MucOptions.Affiliation.MEMBER, onAffiliationChanged);
+                        conversation, jid, Affiliation.MEMBER, onAffiliationChanged);
                 return true;
             case R.id.give_owner_privileges:
                 activity.xmppConnectionService.changeAffiliationInConference(
-                        conversation, jid, MucOptions.Affiliation.OWNER, onAffiliationChanged);
+                        conversation, jid, Affiliation.OWNER, onAffiliationChanged);
                 return true;
             case R.id.remove_membership:
                 activity.xmppConnectionService.changeAffiliationInConference(
-                        conversation, jid, MucOptions.Affiliation.NONE, onAffiliationChanged);
+                        conversation, jid, Affiliation.NONE, onAffiliationChanged);
                 return true;
             case R.id.remove_from_room:
                 removeFromRoom(user, activity, onAffiliationChanged);
                 return true;
             case R.id.ban_from_conference:
                 activity.xmppConnectionService.changeAffiliationInConference(
-                        conversation, jid, MucOptions.Affiliation.OUTCAST, onAffiliationChanged);
-                if (user.getRole() != MucOptions.Role.NONE) {
+                        conversation, jid, Affiliation.OUTCAST, onAffiliationChanged);
+                if (user.getRole() != Role.NONE) {
                     activity.xmppConnectionService.changeRoleInConference(
-                            conversation, user.getName(), MucOptions.Role.NONE);
+                            conversation, user.getName(), Role.NONE);
                 }
                 return true;
             case R.id.send_private_message:
@@ -210,7 +203,7 @@ public final class MucDetailsContextMenuHelper {
                 return true;
             case R.id.invite:
                 // TODO use direct invites for public conferences
-                if (user.getAffiliation().ranks(MucOptions.Affiliation.MEMBER)) {
+                if (user.ranks(Affiliation.MEMBER)) {
                     activity.xmppConnectionService.directInvite(conversation, jid.asBareJid());
                 } else {
                     activity.xmppConnectionService.invite(conversation, jid);
@@ -228,13 +221,10 @@ public final class MucDetailsContextMenuHelper {
         final Conversation conversation = user.getConversation();
         if (conversation.getMucOptions().membersOnly()) {
             activity.xmppConnectionService.changeAffiliationInConference(
-                    conversation,
-                    user.getRealJid(),
-                    MucOptions.Affiliation.NONE,
-                    onAffiliationChanged);
-            if (user.getRole() != MucOptions.Role.NONE) {
+                    conversation, user.getRealJid(), Affiliation.NONE, onAffiliationChanged);
+            if (user.getRole() != Role.NONE) {
                 activity.xmppConnectionService.changeRoleInConference(
-                        conversation, user.getName(), MucOptions.Role.NONE);
+                        conversation, user.getName(), Role.NONE);
             }
         } else {
             final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity);
@@ -259,11 +249,11 @@ public final class MucDetailsContextMenuHelper {
                         activity.xmppConnectionService.changeAffiliationInConference(
                                 conversation,
                                 user.getRealJid(),
-                                MucOptions.Affiliation.OUTCAST,
+                                Affiliation.OUTCAST,
                                 onAffiliationChanged);
-                        if (user.getRole() != MucOptions.Role.NONE) {
+                        if (user.getRole() != Role.NONE) {
                             activity.xmppConnectionService.changeRoleInConference(
-                                    conversation, user.getName(), MucOptions.Role.NONE);
+                                    conversation, user.getName(), Role.NONE);
                         }
                     });
             builder.create().show();

src/main/java/eu/siacs/conversations/utils/Resolver.java πŸ”—

@@ -16,6 +16,7 @@ import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
+import de.gultsch.common.FutureMerger;
 import de.gultsch.minidns.AndroidDNSClient;
 import de.gultsch.minidns.ResolverResult;
 import eu.siacs.conversations.Config;
@@ -115,7 +116,7 @@ public class Resolver {
         final var startTls = resolveSrvAsFuture(domain, false);
         final var directTls = resolveSrvAsFuture(domain, true);
 
-        final var combined = merge(ImmutableList.of(startTls, directTls));
+        final var combined = FutureMerger.successfulAsList(ImmutableList.of(startTls, directTls));
 
         final var combinedWithFallback =
                 Futures.transformAsync(
@@ -206,7 +207,7 @@ public class Resolver {
             futuresBuilder.add(ipv6s);
         }
         final ImmutableList<ListenableFuture<List<Result>>> futures = futuresBuilder.build();
-        return merge(futures);
+        return FutureMerger.successfulAsList(futures);
     }
 
     private static ListenableFuture<List<Result>> merge(
@@ -284,13 +285,13 @@ public class Resolver {
                                         Lists.transform(
                                                 ImmutableList.copyOf(result.getAnswersOrEmptySet()),
                                                 cname -> resolveNoSrvAsFuture(cname.target, false));
-                                return merge(test);
+                                return FutureMerger.successfulAsList(test);
                             },
                             MoreExecutors.directExecutor());
             futuresBuilder.add(cNameRecordResults);
         }
         final ImmutableList<ListenableFuture<List<Result>>> futures = futuresBuilder.build();
-        final var noSrvFallbacks = merge(futures);
+        final var noSrvFallbacks = FutureMerger.successfulAsList(futures);
         return Futures.transform(
                 noSrvFallbacks,
                 results -> {

src/main/java/eu/siacs/conversations/xml/Element.java πŸ”—

@@ -1,6 +1,7 @@
 package eu.siacs.conversations.xml;
 
 import androidx.annotation.NonNull;
+import com.google.common.base.CaseFormat;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.primitives.Ints;
@@ -123,6 +124,16 @@ public class Element {
         return this;
     }
 
+    public Element setAttribute(final String name, final Enum<?> e) {
+        if (e == null) {
+            this.attributes.remove(name);
+        } else {
+            this.attributes.put(
+                    name, CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, e.toString()));
+        }
+        return this;
+    }
+
     public Element setAttribute(String name, Jid value) {
         if (name != null && value != null) {
             this.attributes.put(name, value.toString());

src/main/java/eu/siacs/conversations/xml/Namespace.java πŸ”—

@@ -20,6 +20,7 @@ public final class Namespace {
     public static final String REACTIONS = "urn:xmpp:reactions:0";
     public static final String VCARD_TEMP = "vcard-temp";
     public static final String VCARD_TEMP_UPDATE = "vcard-temp:x:update";
+    public static final String DIRECT_MUC_INVITATIONS = "jabber:x:conference";
     public static final String DELAY = "urn:xmpp:delay";
     public static final String OCCUPANT_ID = "urn:xmpp:occupant-id:0";
     public static final String STREAMS = "http://etherx.jabber.org/streams";
@@ -49,6 +50,9 @@ public final class Namespace {
     public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
     public static final String PUBSUB_EVENT = PUBSUB + "#event";
     public static final String MUC = "http://jabber.org/protocol/muc";
+    public static final String MUC_ADMIN = MUC + "#admin";
+    public static final String MUC_OWNER = MUC + "#owner";
+    public static final String MUC_USER = MUC + "#user";
     public static final String MUC_ROOM_INFO = MUC + "#roominfo";
     public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options";
     public static final String PUBSUB_CONFIG_NODE_MAX = PUBSUB + "#config-node-max";
@@ -96,7 +100,6 @@ public final class Namespace {
     public static final String PING = "urn:xmpp:ping";
     public static final String PUSH = "urn:xmpp:push:0";
     public static final String COMMANDS = "http://jabber.org/protocol/commands";
-    public static final String MUC_USER = "http://jabber.org/protocol/muc#user";
     public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:1";
     public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat";
     public static final String PRE_AUTHENTICATED_IN_BAND_REGISTRATION = "urn:xmpp:ibr-token:0";
@@ -114,7 +117,6 @@ public final class Namespace {
     public static final String MEDIA_ELEMENT = "urn:xmpp:media-element";
     public static final String MDS_DISPLAYED = "urn:xmpp:mds:displayed:0";
     public static final String MDS_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0";
-
     public static final String ENTITY_CAPABILITIES = "http://jabber.org/protocol/caps";
     public static final String ENTITY_CAPABILITIES_2 = "urn:xmpp:caps";
     public static final String PRIVATE_XML_STORAGE = "jabber:iq:private";

src/main/java/eu/siacs/conversations/xmpp/IqErrorException.java πŸ”—

@@ -18,6 +18,14 @@ public class IqErrorException extends Exception {
         return this.response.getError();
     }
 
+    public Condition getErrorCondition() {
+        final var error = getError();
+        if (error == null) {
+            return null;
+        }
+        return error.getCondition();
+    }
+
     private static String getErrorText(final Iq response) {
         final var error = response.getError();
         final var text = error == null ? null : error.getText();

src/main/java/eu/siacs/conversations/xmpp/Managers.java πŸ”—

@@ -15,6 +15,7 @@ import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
 import eu.siacs.conversations.xmpp.manager.LegacyBookmarkManager;
 import eu.siacs.conversations.xmpp.manager.MessageDisplayedSynchronizationManager;
 import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
+import eu.siacs.conversations.xmpp.manager.NativeBookmarkManager;
 import eu.siacs.conversations.xmpp.manager.NickManager;
 import eu.siacs.conversations.xmpp.manager.OfflineMessagesManager;
 import eu.siacs.conversations.xmpp.manager.PepManager;
@@ -50,6 +51,7 @@ public class Managers {
                         MessageDisplayedSynchronizationManager.class,
                         new MessageDisplayedSynchronizationManager(context, connection))
                 .put(MultiUserChatManager.class, new MultiUserChatManager(context, connection))
+                .put(NativeBookmarkManager.class, new NativeBookmarkManager(context, connection))
                 .put(NickManager.class, new NickManager(context, connection))
                 .put(OfflineMessagesManager.class, new OfflineMessagesManager(context, connection))
                 .put(PepManager.class, new PepManager(context, connection))

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java πŸ”—

@@ -18,7 +18,9 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ClassToInstanceMap;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.FutureCallback;
@@ -71,6 +73,7 @@ import eu.siacs.conversations.xmpp.manager.AbstractManager;
 import eu.siacs.conversations.xmpp.manager.BlockingManager;
 import eu.siacs.conversations.xmpp.manager.CarbonsManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import eu.siacs.conversations.xmpp.manager.PingManager;
 import eu.siacs.conversations.xmpp.manager.RegistrationManager;
 import im.conversations.android.xmpp.Entity;
@@ -85,7 +88,6 @@ import im.conversations.android.xmpp.model.bind2.Bound;
 import im.conversations.android.xmpp.model.cb.SaslChannelBinding;
 import im.conversations.android.xmpp.model.csi.Active;
 import im.conversations.android.xmpp.model.csi.Inactive;
-import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.error.Condition;
 import im.conversations.android.xmpp.model.fast.Fast;
 import im.conversations.android.xmpp.model.fast.RequestToken;
@@ -135,7 +137,6 @@ import java.util.HashSet;
 import java.util.Hashtable;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -2632,29 +2633,10 @@ public class XmppConnection implements Runnable {
         return this.managers.getInstance(clazz);
     }
 
-    public List<String> getMucServersWithholdAccount() {
-        final List<String> servers = getMucServers();
-        servers.remove(account.getDomain().toString());
-        return servers;
-    }
-
-    public List<String> getMucServers() {
-        List<String> servers = new ArrayList<>();
-        for (final Entry<Jid, InfoQuery> entry :
-                getManager(DiscoManager.class).getServerItems().entrySet()) {
-            final var value = entry.getValue();
-            if (value.getFeatureStrings().contains("http://jabber.org/protocol/muc")
-                    && value.hasIdentityWithCategoryAndType("conference", "text")
-                    && !value.getFeatureStrings().contains("jabber:iq:gateway")
-                    && !value.hasIdentityWithCategoryAndType("conference", "irc")) {
-                servers.add(entry.getKey().toString());
-            }
-        }
-        return servers;
-    }
-
-    public String getMucServer() {
-        return Iterables.getFirst(getMucServers(), null);
+    public Set<Jid> getMucServersWithholdAccount() {
+        final var services = getManager(MultiUserChatManager.class).getServices();
+        return ImmutableSet.copyOf(
+                Collections2.filter(services, s -> !s.equals(account.getDomain())));
     }
 
     public int getTimeToNextAttempt(final boolean aggressive) {

src/main/java/eu/siacs/conversations/xmpp/manager/AbstractBookmarkManager.java πŸ”—

@@ -28,7 +28,7 @@ public class AbstractBookmarkManager extends AbstractManager {
         final Set<Jid> previousBookmarks = account.getBookmarkedJids();
         for (final Bookmark bookmark : bookmarks.values()) {
             previousBookmarks.remove(bookmark.getJid().asBareJid());
-            service.processModifiedBookmark(bookmark, pep);
+            getManager(BookmarkManager.class).processModifiedBookmark(bookmark, pep);
         }
         if (pep) {
             this.processDeletedBookmarks(previousBookmarks);

src/main/java/eu/siacs/conversations/xmpp/manager/BookmarkManager.java πŸ”—

@@ -1,150 +1,176 @@
 package eu.siacs.conversations.xmpp.manager;
 
+import android.text.TextUtils;
 import android.util.Log;
 import androidx.annotation.NonNull;
-import com.google.common.collect.ImmutableMap;
 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 eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Bookmark;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
-import im.conversations.android.xmpp.NodeConfiguration;
-import im.conversations.android.xmpp.model.bookmark2.Conference;
-import im.conversations.android.xmpp.model.bookmark2.Nick;
-import im.conversations.android.xmpp.model.bookmark2.Password;
-import im.conversations.android.xmpp.model.pubsub.Items;
-import im.conversations.android.xmpp.model.pubsub.event.Retract;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
 
-public class BookmarkManager extends AbstractBookmarkManager {
+public class BookmarkManager extends AbstractManager {
 
-    public BookmarkManager(final XmppConnectionService service, XmppConnection connection) {
-        super(service, connection);
+    private final XmppConnectionService service;
+
+    public BookmarkManager(final XmppConnectionService service, final XmppConnection connection) {
+        super(service.getApplicationContext(), connection);
+        this.service = service;
+    }
+
+    public void request() {
+        if (getManager(NativeBookmarkManager.class).hasFeature()) {
+            getManager(NativeBookmarkManager.class).fetch();
+        } else if (getManager(LegacyBookmarkManager.class).hasConversion()) {
+            final var account = getAccount();
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid() + ": not fetching bookmarks. waiting for server to push");
+        } else {
+            getManager(PrivateStorageManager.class).fetchBookmarks();
+        }
     }
 
-    public void fetch() {
-        final var future = getManager(PepManager.class).fetchItems(Conference.class);
+    public void save(final Conversation conversation, final String name) {
+        final Account account = conversation.getAccount();
+        final Bookmark bookmark = new Bookmark(account, conversation.getJid().asBareJid());
+        final String nick = conversation.getJid().getResource();
+        if (nick != null && !nick.isEmpty() && !nick.equals(MucOptions.defaultNick(account))) {
+            bookmark.setNick(nick);
+        }
+        if (!TextUtils.isEmpty(name)) {
+            bookmark.setBookmarkName(name);
+        }
+        bookmark.setAutojoin(true);
+        this.create(bookmark);
+        bookmark.setConversation(conversation);
+    }
+
+    public void create(final Bookmark bookmark) {
+        final var account = getAccount();
+        account.putBookmark(bookmark);
+        final ListenableFuture<Void> future;
+        if (getManager(NativeBookmarkManager.class).hasFeature()) {
+            future = getManager(NativeBookmarkManager.class).publish(bookmark);
+        } else if (getManager(LegacyBookmarkManager.class).hasConversion()) {
+            future = getManager(LegacyBookmarkManager.class).publish(account.getBookmarks());
+        } else {
+            future =
+                    getManager(PrivateStorageManager.class)
+                            .publishBookmarks(account.getBookmarks());
+        }
         Futures.addCallback(
                 future,
                 new FutureCallback<>() {
                     @Override
-                    public void onSuccess(final Map<String, Conference> bookmarks) {
-                        final var builder = new ImmutableMap.Builder<Jid, Bookmark>();
-                        for (final var entry : bookmarks.entrySet()) {
-                            final Bookmark bookmark =
-                                    Bookmark.parseFromItem(
-                                            entry.getKey(), entry.getValue(), getAccount());
-                            if (bookmark == null) {
-                                continue;
-                            }
-                            builder.put(bookmark.getJid(), bookmark);
-                        }
-                        processBookmarksInitial(builder.buildKeepingLast(), true);
+                    public void onSuccess(Void result) {
+                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": created bookmark");
                     }
 
                     @Override
-                    public void onFailure(@NonNull final Throwable throwable) {
-                        Log.d(Config.LOGTAG, "Could not fetch bookmarks", throwable);
+                    public void onFailure(@NonNull Throwable t) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid() + ": could not create bookmark",
+                                t);
                     }
                 },
                 MoreExecutors.directExecutor());
     }
 
-    public void handleItems(final Items items) {
-        this.handleItems(items.getItemMap(Conference.class));
-        this.handleRetractions(items.getRetractions());
-    }
-
-    private void handleRetractions(final Collection<Retract> retractions) {
+    public void delete(final Bookmark bookmark) {
         final var account = getAccount();
-        for (final var retract : retractions) {
-            final Jid id = Jid.Invalid.getNullForInvalid(retract.getAttributeAsJid("id"));
-            if (id != null) {
-                account.removeBookmark(id);
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmark for " + id);
-                processDeletedBookmark(id);
-                service.updateConversationUi();
-            }
+        account.removeBookmark(bookmark);
+        final ListenableFuture<Void> future;
+        if (getManager(NativeBookmarkManager.class).hasFeature()) {
+            future = getManager(NativeBookmarkManager.class).retract(bookmark.getJid().asBareJid());
+        } else if (getManager(LegacyBookmarkManager.class).hasConversion()) {
+            future = getManager(LegacyBookmarkManager.class).publish(account.getBookmarks());
+        } else {
+            future =
+                    getManager(PrivateStorageManager.class)
+                            .publishBookmarks(account.getBookmarks());
         }
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(Void result) {
+                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmark");
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid() + ": could not delete bookmark",
+                                t);
+                    }
+                },
+                MoreExecutors.directExecutor());
     }
 
-    private void handleItems(final Map<String, Conference> items) {
+    public void ensureBookmarkIsAutoJoin(final Conversation conversation) {
         final var account = getAccount();
-        for (final var item : items.entrySet()) {
-            // TODO parseFromItem can be included in this Manager
-            final Bookmark bookmark =
-                    Bookmark.parseFromItem(item.getKey(), item.getValue(), account);
-            if (bookmark == null) {
-                continue;
+        final var existingBookmark = conversation.getBookmark();
+        if (existingBookmark == null) {
+            final var bookmark = new Bookmark(account, conversation.getJid().asBareJid());
+            bookmark.setAutojoin(true);
+            create(bookmark);
+        } else {
+            if (existingBookmark.autojoin()) {
+                return;
             }
-            account.putBookmark(bookmark);
-            service.processModifiedBookmark(bookmark);
-            service.updateConversationUi();
+            existingBookmark.setAutojoin(true);
+            create(existingBookmark);
         }
     }
 
-    public ListenableFuture<Void> publish(final Bookmark bookmark) {
-        final var address = bookmark.getJid();
-        final var name = bookmark.getBookmarkName();
-        final var nick = bookmark.getNick();
-        final String password = bookmark.getPassword();
-        final var itemId = address.toString();
-        final var conference = new Conference();
-        conference.setAutoJoin(bookmark.autojoin());
-        if (nick != null) {
-            conference.addExtension(new Nick()).setContent(nick);
-        }
-        if (name != null) {
-            conference.setConferenceName(name);
-        }
-        if (password != null) {
-            conference.addExtension(new Password()).setContent(password);
+    public void processModifiedBookmark(final Bookmark bookmark, final boolean pep) {
+        final var existing = this.service.find(bookmark);
+        if (existing != null) {
+            if (existing.getMode() != Conversation.MODE_MULTI) {
+                return;
+            }
+            bookmark.setConversation(existing);
+            if (pep && !bookmark.autojoin()) {
+                Log.d(
+                        Config.LOGTAG,
+                        getAccount().getJid().asBareJid()
+                                + ": archiving conference ("
+                                + existing.getJid()
+                                + ") after receiving pep");
+                service.archiveConversation(existing, false);
+            } else {
+                final MucOptions mucOptions = existing.getMucOptions();
+                if (mucOptions.getError() == MucOptions.Error.NICK_IN_USE) {
+                    final String current = mucOptions.getActualNick();
+                    final String proposed = mucOptions.getProposedNickPure();
+                    if (current != null && !current.equals(proposed)) {
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": proposed nick changed after bookmark push "
+                                        + current
+                                        + "->"
+                                        + proposed);
+                        getManager(MultiUserChatManager.class).join(existing);
+                    }
+                } else {
+                    getManager(MultiUserChatManager.class).checkMucRequiresRename(existing);
+                }
+            }
+        } else if (bookmark.autojoin()) {
+            final var fresh =
+                    this.service.findOrCreateConversation(
+                            getAccount(), bookmark.getFullJid(), true, true, false);
+            bookmark.setConversation(fresh);
         }
-        conference.addExtension(bookmark.getExtensions());
-        return Futures.transform(
-                getManager(PepManager.class)
-                        .publish(conference, itemId, NodeConfiguration.WHITELIST_MAX_ITEMS),
-                result -> null,
-                MoreExecutors.directExecutor());
-    }
-
-    public ListenableFuture<Void> retract(final Jid address) {
-        final var itemId = address.toString();
-        return Futures.transform(
-                getManager(PepManager.class).retract(itemId, Namespace.BOOKMARKS2),
-                result -> null,
-                MoreExecutors.directExecutor());
-    }
-
-    private void deleteAllItems() {
-        final var account = getAccount();
-        final var previous = account.getBookmarkedJids();
-        account.setBookmarks(Collections.emptyMap());
-        processDeletedBookmarks(previous);
-    }
-
-    public void handleDelete() {
-        Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + ": deleted bookmarks node");
-        this.deleteAllItems();
-    }
-
-    public void handlePurge() {
-        Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + ": purged bookmarks");
-        this.deleteAllItems();
-    }
-
-    public boolean hasFeature() {
-        final var pep = getManager(PepManager.class);
-        final var disco = getManager(DiscoManager.class);
-        return pep.hasPublishOptions()
-                && pep.hasConfigNodeMax()
-                && disco.hasAccountFeature(Namespace.BOOKMARKS2_COMPAT);
     }
 }

src/main/java/eu/siacs/conversations/xmpp/manager/DiscoManager.java πŸ”—

@@ -336,7 +336,7 @@ public class DiscoManager extends AbstractManager {
         if (appSettings.isBroadcastLastActivity()) {
             features.add(Namespace.IDLE);
         }
-        if (getManager(BookmarkManager.class).hasFeature()) {
+        if (getManager(NativeBookmarkManager.class).hasFeature()) {
             features.add(Namespace.BOOKMARKS2 + "+notify");
         } else {
             features.add(Namespace.BOOKMARKS + "+notify");

src/main/java/eu/siacs/conversations/xmpp/manager/LegacyBookmarkManager.java πŸ”—

@@ -24,7 +24,7 @@ public class LegacyBookmarkManager extends AbstractBookmarkManager {
     public void handleItems(final Items items) {
         final var account = this.getAccount();
         if (this.hasConversion()) {
-            if (getManager(BookmarkManager.class).hasFeature()) {
+            if (getManager(NativeBookmarkManager.class).hasFeature()) {
                 Log.w(
                         Config.LOGTAG,
                         account.getJid().asBareJid()

src/main/java/eu/siacs/conversations/xmpp/manager/MultiUserChatManager.java πŸ”—

@@ -1,14 +1,988 @@
 package eu.siacs.conversations.xmpp.manager;
 
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+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 com.google.common.util.concurrent.SettableFuture;
+import de.gultsch.common.FutureMerger;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Bookmark;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Conversational;
+import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.CryptoHelper;
+import eu.siacs.conversations.utils.StringUtils;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.Entity;
+import im.conversations.android.xmpp.IqErrorException;
+import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.conference.DirectInvite;
+import im.conversations.android.xmpp.model.data.Data;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.hints.NoCopy;
+import im.conversations.android.xmpp.model.hints.NoStore;
+import im.conversations.android.xmpp.model.jabber.Subject;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.History;
+import im.conversations.android.xmpp.model.muc.MultiUserChat;
+import im.conversations.android.xmpp.model.muc.Password;
+import im.conversations.android.xmpp.model.muc.Role;
+import im.conversations.android.xmpp.model.muc.admin.Item;
+import im.conversations.android.xmpp.model.muc.admin.MucAdmin;
+import im.conversations.android.xmpp.model.muc.owner.Destroy;
+import im.conversations.android.xmpp.model.muc.owner.MucOwner;
+import im.conversations.android.xmpp.model.muc.user.Invite;
+import im.conversations.android.xmpp.model.muc.user.MucUser;
+import im.conversations.android.xmpp.model.pgp.Signed;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.stanza.Message;
+import im.conversations.android.xmpp.model.stanza.Presence;
+import im.conversations.android.xmpp.model.vcard.update.VCardUpdate;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public class MultiUserChatManager extends AbstractManager {
 
     private final XmppConnectionService service;
 
+    private final Set<Conversation> inProgressConferenceJoins = new HashSet<>();
+    private final Set<Conversation> inProgressConferencePings = new HashSet<>();
+
     public MultiUserChatManager(final XmppConnectionService service, XmppConnection connection) {
         super(service.getApplicationContext(), connection);
         this.service = service;
     }
+
+    public ListenableFuture<Void> join(final Conversation conversation) {
+        return join(conversation, true);
+    }
+
+    private ListenableFuture<Void> join(
+            final Conversation conversation, final boolean autoPushConfiguration) {
+        final var account = getAccount();
+        synchronized (this.inProgressConferenceJoins) {
+            this.inProgressConferenceJoins.add(conversation);
+        }
+        if (Config.MUC_LEAVE_BEFORE_JOIN) {
+            unavailable(conversation);
+        }
+        conversation.resetMucOptions();
+        conversation.getMucOptions().setAutoPushConfiguration(autoPushConfiguration);
+        conversation.setHasMessagesLeftOnServer(false);
+        final var disco = fetchDiscoInfo(conversation);
+
+        final var caughtDisco =
+                Futures.catchingAsync(
+                        disco,
+                        IqErrorException.class,
+                        ex -> {
+                            if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
+                                return Futures.immediateFailedFuture(
+                                        new IllegalStateException(
+                                                "conversation got archived before disco returned"));
+                            }
+                            Log.d(Config.LOGTAG, "error fetching disco#info", ex);
+                            final var iqError = ex.getError();
+                            if (iqError != null
+                                    && iqError.getCondition()
+                                            instanceof Condition.RemoteServerNotFound) {
+                                synchronized (this.inProgressConferenceJoins) {
+                                    this.inProgressConferenceJoins.remove(conversation);
+                                }
+                                conversation
+                                        .getMucOptions()
+                                        .setError(MucOptions.Error.SERVER_NOT_FOUND);
+                                service.updateConversationUi();
+                                return Futures.immediateFailedFuture(ex);
+                            } else {
+                                return Futures.immediateFuture(new InfoQuery());
+                            }
+                        },
+                        MoreExecutors.directExecutor());
+
+        return Futures.transform(
+                caughtDisco,
+                v -> {
+                    checkConfigurationSendPresenceFetchHistory(conversation);
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> joinFollowingInvite(final Conversation conversation) {
+        // TODO this special treatment is probably unnecessary; just always make sure the bookmark
+        // exists
+        return Futures.transform(
+                join(conversation),
+                v -> {
+                    // we used to do this only for private groups
+                    final Bookmark bookmark = conversation.getBookmark();
+                    if (bookmark != null) {
+                        if (bookmark.autojoin()) {
+                            return null;
+                        }
+                        bookmark.setAutojoin(true);
+                        getManager(BookmarkManager.class).create(bookmark);
+                    } else {
+                        getManager(BookmarkManager.class).save(conversation, null);
+                    }
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void checkConfigurationSendPresenceFetchHistory(final Conversation conversation) {
+
+        Account account = conversation.getAccount();
+        final MucOptions mucOptions = conversation.getMucOptions();
+
+        if (mucOptions.nonanonymous()
+                && !mucOptions.membersOnly()
+                && !conversation.getBooleanAttribute("accept_non_anonymous", false)) {
+            synchronized (this.inProgressConferenceJoins) {
+                this.inProgressConferenceJoins.remove(conversation);
+            }
+            mucOptions.setError(MucOptions.Error.NON_ANONYMOUS);
+            service.updateConversationUi();
+            return;
+        }
+
+        final Jid joinJid = mucOptions.getSelf().getFullJid();
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid().toString()
+                        + ": joining conversation "
+                        + joinJid.toString());
+
+        final var x = new MultiUserChat();
+
+        if (mucOptions.getPassword() != null) {
+            x.addExtension(new Password(mucOptions.getPassword()));
+        }
+
+        final var history = x.addExtension(new History());
+
+        if (mucOptions.mamSupport()) {
+            // Use MAM instead of the limited muc history to get history
+            history.setMaxStanzas(0);
+        } else {
+            // Fallback to muc history
+            history.setSince(conversation.getLastMessageTransmitted().getTimestamp());
+        }
+        available(joinJid, mucOptions.nonanonymous(), x);
+        if (!joinJid.equals(conversation.getJid())) {
+            conversation.setContactJid(joinJid);
+            getDatabase().updateConversation(conversation);
+        }
+
+        if (mucOptions.mamSupport()) {
+            this.service.getMessageArchiveService().catchupMUC(conversation);
+        }
+        if (mucOptions.isPrivateAndNonAnonymous()) {
+            fetchMembers(conversation);
+        }
+        synchronized (this.inProgressConferenceJoins) {
+            this.inProgressConferenceJoins.remove(conversation);
+            this.service.sendUnsentMessages(conversation);
+        }
+    }
+
+    public ListenableFuture<Conversation> createPrivateGroupChat(
+            final String name, final Collection<Jid> addresses) {
+        final var service = getService();
+        if (service == null) {
+            return Futures.immediateFailedFuture(new IllegalStateException("No MUC service found"));
+        }
+        final var address = Jid.ofLocalAndDomain(CryptoHelper.pronounceable(), service);
+        final var conversation =
+                this.service.findOrCreateConversation(getAccount(), address, true, false, true);
+        final var join = this.join(conversation, false);
+        final var configured =
+                Futures.transformAsync(
+                        join,
+                        v -> {
+                            final var options =
+                                    configWithName(defaultGroupChatConfiguration(), name);
+                            return pushConfiguration(conversation, options);
+                        },
+                        MoreExecutors.directExecutor());
+
+        // TODO add catching to 'configured' to archive the chat again
+
+        return Futures.transform(
+                configured,
+                c -> {
+                    for (var invitee : addresses) {
+                        this.service.invite(conversation, invitee);
+                    }
+                    final var account = getAccount();
+                    for (final var resource :
+                            account.getSelfContact().getPresences().toResourceArray()) {
+                        Jid other = getAccount().getJid().withResource(resource);
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": sending direct invite to "
+                                        + other);
+                        this.service.directInvite(conversation, other);
+                    }
+                    getManager(BookmarkManager.class).save(conversation, name);
+                    return conversation;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Conversation> createPublicChannel(
+            final Jid address, final String name) {
+
+        final var conversation =
+                this.service.findOrCreateConversation(getAccount(), address, true, false, true);
+
+        final var join = this.join(conversation, false);
+        final var configuration =
+                Futures.transformAsync(
+                        join,
+                        v -> {
+                            final var options = configWithName(defaultChannelConfiguration(), name);
+                            return pushConfiguration(conversation, options);
+                        },
+                        MoreExecutors.directExecutor());
+
+        // TODO mostly ignore configuration error
+
+        return Futures.transform(
+                configuration,
+                v -> {
+                    getManager(BookmarkManager.class).save(conversation, name);
+                    return conversation;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public void leave(final Conversation conversation) {
+        final var mucOptions = conversation.getMucOptions();
+        mucOptions.setOffline();
+        getManager(DiscoManager.class).clear(conversation.getJid().asBareJid());
+        unavailable(conversation);
+    }
+
+    public void handlePresence(final Presence presence) {}
+
+    public void handleStatusMessage(final Message message) {
+        final var from = Jid.Invalid.getNullForInvalid(message.getFrom());
+        final var mucUser = message.getExtension(MucUser.class);
+        if (from == null || from.isFullJid() || mucUser == null) {
+            return;
+        }
+        final var conversation = this.service.find(getAccount(), from);
+        if (conversation == null || conversation.getMode() != Conversation.MODE_MULTI) {
+            return;
+        }
+        for (final var status : mucUser.getStatus()) {
+            handleStatusCode(conversation, status);
+        }
+        final var item = mucUser.getItem();
+        if (item == null) {
+            return;
+        }
+        final var user = itemToUser(conversation, item, null);
+        this.handleAffiliationChange(conversation, user);
+    }
+
+    private void handleAffiliationChange(
+            final Conversation conversation, final MucOptions.User user) {
+        final var account = getAccount();
+        Log.d(
+                Config.LOGTAG,
+                account.getJid()
+                        + ": changing affiliation for "
+                        + user.getRealJid()
+                        + " to "
+                        + user.getAffiliation()
+                        + " in "
+                        + conversation.getJid().asBareJid());
+        if (user.realJidMatchesAccount()) {
+            return;
+        }
+        final var mucOptions = conversation.getMucOptions();
+        final boolean isNew = mucOptions.updateUser(user);
+        final var avatarService = this.service.getAvatarService();
+        if (Strings.isNullOrEmpty(mucOptions.getAvatar())) {
+            avatarService.clear(mucOptions);
+        }
+        avatarService.clear(user);
+        this.service.updateMucRosterUi();
+        this.service.updateConversationUi();
+        if (user.ranks(Affiliation.MEMBER)) {
+            fetchDeviceIdsIfNeeded(isNew, user);
+        } else {
+            final var jid = user.getRealJid();
+            final var cryptoTargets = conversation.getAcceptedCryptoTargets();
+            if (cryptoTargets.remove(user.getRealJid())) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": removed "
+                                + jid
+                                + " from crypto targets of "
+                                + conversation.getName());
+                conversation.setAcceptedCryptoTargets(cryptoTargets);
+                getDatabase().updateConversation(conversation);
+            }
+        }
+    }
+
+    private void fetchDeviceIdsIfNeeded(final boolean isNew, final MucOptions.User user) {
+        final var contact = user.getContact();
+        final var mucOptions = user.getMucOptions();
+        final var axolotlService = connection.getAxolotlService();
+        if (isNew
+                && user.getRealJid() != null
+                && mucOptions.isPrivateAndNonAnonymous()
+                && (contact == null || !contact.mutualPresenceSubscription())
+                && axolotlService.hasEmptyDeviceList(user.getRealJid())) {
+            axolotlService.fetchDeviceIds(user.getRealJid());
+        }
+    }
+
+    private void handleStatusCode(final Conversation conversation, final int status) {
+        if ((status >= 170 && status <= 174) || (status >= 102 && status <= 104)) {
+            Log.d(
+                    Config.LOGTAG,
+                    getAccount().getJid().asBareJid()
+                            + ": fetching disco#info on status code "
+                            + status);
+            getManager(MultiUserChatManager.class).fetchDiscoInfo(conversation);
+        }
+    }
+
+    public ListenableFuture<Void> fetchDiscoInfo(final Conversation conversation) {
+        final var address = conversation.getJid().asBareJid();
+        final var future =
+                connection.getManager(DiscoManager.class).info(Entity.discoItem(address), null);
+        return Futures.transform(
+                future,
+                infoQuery -> {
+                    setDiscoInfo(conversation, infoQuery);
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void setDiscoInfo(final Conversation conversation, final InfoQuery result) {
+        final var account = conversation.getAccount();
+        final var address = conversation.getJid().asBareJid();
+        final var avatarHash =
+                result.getServiceDiscoveryExtension(
+                        Namespace.MUC_ROOM_INFO, "muc#roominfo_avatarhash");
+        if (VCardUpdate.isValidSHA1(avatarHash)) {
+            connection.getManager(AvatarManager.class).handleVCardUpdate(address, avatarHash);
+        }
+        final MucOptions mucOptions = conversation.getMucOptions();
+        final Bookmark bookmark = conversation.getBookmark();
+        final boolean sameBefore =
+                StringUtils.equals(
+                        bookmark == null ? null : bookmark.getBookmarkName(), mucOptions.getName());
+
+        final var hadOccupantId = mucOptions.occupantId();
+        if (mucOptions.updateConfiguration(result)) {
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": muc configuration changed for "
+                            + conversation.getJid().asBareJid());
+            getDatabase().updateConversation(conversation);
+        }
+
+        final var hasOccupantId = mucOptions.occupantId();
+
+        if (!hadOccupantId && hasOccupantId && mucOptions.online()) {
+            final var me = mucOptions.getSelf().getFullJid();
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": gained support for occupant-id in "
+                            + me
+                            + ". resending presence");
+            this.available(me, mucOptions.nonanonymous());
+        }
+
+        if (bookmark != null && (sameBefore || bookmark.getBookmarkName() == null)) {
+            if (bookmark.setBookmarkName(StringUtils.nullOnEmpty(mucOptions.getName()))) {
+                getManager(BookmarkManager.class).create(bookmark);
+            }
+        }
+        this.service.updateConversationUi();
+    }
+
+    public void resendPresence(final Conversation conversation) {
+        final MucOptions mucOptions = conversation.getMucOptions();
+        if (mucOptions.online()) {
+            available(mucOptions.getSelf().getFullJid(), mucOptions.nonanonymous());
+        }
+    }
+
+    private void available(
+            final Jid address, final boolean nonAnonymous, final Extension... extensions) {
+        final var presenceManager = getManager(PresenceManager.class);
+        final var account = getAccount();
+        final String pgpSignature = account.getPgpSignature();
+        if (nonAnonymous && pgpSignature != null) {
+            final String message = account.getPresenceStatusMessage();
+            presenceManager.available(
+                    address, message, combine(extensions, new Signed(pgpSignature)));
+        } else {
+            presenceManager.available(address, extensions);
+        }
+    }
+
+    public void unavailable(final Conversation conversation) {
+        final var mucOptions = conversation.getMucOptions();
+        getManager(PresenceManager.class).unavailable(mucOptions.getSelf().getFullJid());
+    }
+
+    private static Extension[] combine(final Extension[] extensions, final Extension extension) {
+        return new ImmutableList.Builder<Extension>()
+                .addAll(Arrays.asList(extensions))
+                .add(extension)
+                .build()
+                .toArray(new Extension[0]);
+    }
+
+    public ListenableFuture<Void> pushConfiguration(
+            final Conversation conversation, final Map<String, Object> input) {
+        final var address = conversation.getJid().asBareJid();
+        final var configuration = modifyBestInteroperability(input);
+
+        if (configuration.get("muc#roomconfig_whois") instanceof String whois
+                && whois.equals("anyone")) {
+            conversation.setAttribute("accept_non_anonymous", true);
+            getDatabase().updateConversation(conversation);
+        }
+
+        final var future = fetchConfigurationForm(address);
+        return Futures.transformAsync(
+                future,
+                current -> {
+                    final var modified = current.submit(configuration);
+                    return submitConfigurationForm(address, modified);
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Data> fetchConfigurationForm(final Jid address) {
+        final var iq = new Iq(Iq.Type.GET, new MucOwner());
+        iq.setTo(address);
+        Log.d(Config.LOGTAG, "fetching configuration form: " + iq);
+        return Futures.transform(
+                connection.sendIqPacket(iq),
+                response -> {
+                    final var mucOwner = response.getExtension(MucOwner.class);
+                    if (mucOwner == null) {
+                        throw new IllegalStateException("Missing MucOwner element in response");
+                    }
+                    return mucOwner.getConfiguration();
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Void> submitConfigurationForm(final Jid address, final Data data) {
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var mucOwner = iq.addExtension(new MucOwner());
+        mucOwner.addExtension(data);
+        Log.d(Config.LOGTAG, "pushing configuration form: " + iq);
+        return Futures.transform(
+                this.connection.sendIqPacket(iq), response -> null, MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> fetchMembers(final Conversation conversation) {
+        final var futures =
+                Collections2.transform(
+                        Arrays.asList(Affiliation.OWNER, Affiliation.ADMIN, Affiliation.MEMBER),
+                        a -> fetchAffiliations(conversation, a));
+        ListenableFuture<List<MucOptions.User>> future = FutureMerger.allAsList(futures);
+        return Futures.transform(
+                future,
+                members -> {
+                    setMembers(conversation, members);
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void setMembers(final Conversation conversation, final List<MucOptions.User> users) {
+        for (final var user : users) {
+            if (user.realJidMatchesAccount()) {
+                continue;
+            }
+            boolean isNew = conversation.getMucOptions().updateUser(user);
+            fetchDeviceIdsIfNeeded(isNew, user);
+        }
+        final var mucOptions = conversation.getMucOptions();
+        final var members = mucOptions.getMembers(true);
+        final var cryptoTargets = conversation.getAcceptedCryptoTargets();
+        boolean changed = false;
+        for (final var iterator = cryptoTargets.listIterator(); iterator.hasNext(); ) {
+            final var jid = iterator.next();
+            if (!members.contains(jid) && !members.contains(jid.getDomain())) {
+                iterator.remove();
+                Log.d(
+                        Config.LOGTAG,
+                        getAccount().getJid().asBareJid()
+                                + ": removed "
+                                + jid
+                                + " from crypto targets of "
+                                + conversation.getName());
+                changed = true;
+            }
+        }
+        if (changed) {
+            conversation.setAcceptedCryptoTargets(cryptoTargets);
+            getDatabase().updateConversation(conversation);
+        }
+        // TODO only when room has no avatar
+        this.service.getAvatarService().clear(mucOptions);
+        this.service.updateMucRosterUi();
+        this.service.updateConversationUi();
+    }
+
+    private ListenableFuture<Collection<MucOptions.User>> fetchAffiliations(
+            final Conversation conversation, final Affiliation affiliation) {
+        final var iq = new Iq(Iq.Type.GET);
+        iq.setTo(conversation.getJid().asBareJid());
+        iq.addExtension(new MucAdmin()).addExtension(new Item()).setAffiliation(affiliation);
+        return Futures.transform(
+                this.connection.sendIqPacket(iq),
+                response -> {
+                    final var mucAdmin = response.getExtension(MucAdmin.class);
+                    if (mucAdmin == null) {
+                        throw new IllegalStateException("No query in response");
+                    }
+                    return Collections2.transform(
+                            mucAdmin.getItems(), i -> itemToUser(conversation, i, null));
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> changeUsername(
+            final Conversation conversation, final String username) {
+
+        // TODO when online send normal available presence
+        // TODO when not online do a normal join
+
+        final Bookmark bookmark = conversation.getBookmark();
+        final MucOptions options = conversation.getMucOptions();
+        final Jid joinJid = options.createJoinJid(username);
+        if (joinJid == null) {
+            return Futures.immediateFailedFuture(new IllegalArgumentException());
+        }
+
+        if (options.online()) {
+            final SettableFuture<Void> renameFuture = SettableFuture.create();
+            options.setOnRenameListener(
+                    new MucOptions.OnRenameListener() {
+
+                        @Override
+                        public void onSuccess() {
+                            renameFuture.set(null);
+                        }
+
+                        @Override
+                        public void onFailure() {
+                            renameFuture.setException(new IllegalStateException());
+                        }
+                    });
+
+            available(joinJid, options.nonanonymous());
+
+            if (username.equals(MucOptions.defaultNick(getAccount()))
+                    && bookmark != null
+                    && bookmark.getNick() != null) {
+                Log.d(
+                        Config.LOGTAG,
+                        getAccount().getJid().asBareJid()
+                                + ": removing nick from bookmark for "
+                                + bookmark.getJid());
+                bookmark.setNick(null);
+                getManager(BookmarkManager.class).create(bookmark);
+            }
+            return renameFuture;
+        } else {
+            conversation.setContactJid(joinJid);
+            getDatabase().updateConversation(conversation);
+            if (bookmark != null) {
+                bookmark.setNick(username);
+                getManager(BookmarkManager.class).create(bookmark);
+            }
+            join(conversation);
+            return Futures.immediateVoidFuture();
+        }
+    }
+
+    public void checkMucRequiresRename(final Conversation conversation) {
+        final var options = conversation.getMucOptions();
+        if (!options.online()) {
+            return;
+        }
+        final String current = options.getActualNick();
+        final String proposed = options.getProposedNickPure();
+        if (current == null || current.equals(proposed)) {
+            return;
+        }
+        final Jid joinJid = options.createJoinJid(proposed);
+        Log.d(
+                Config.LOGTAG,
+                String.format(
+                        "%s: muc rename required %s (was: %s)",
+                        getAccount().getJid().asBareJid(), joinJid, current));
+        available(joinJid, options.nonanonymous());
+    }
+
+    public void setPassword(final Conversation conversation, final String password) {
+        final var bookmark = conversation.getBookmark();
+        conversation.getMucOptions().setPassword(password);
+        if (bookmark != null) {
+            bookmark.setAutojoin(true);
+            getManager(BookmarkManager.class).create(bookmark);
+        }
+        getDatabase().updateConversation(conversation);
+        this.join(conversation);
+    }
+
+    public void pingAndRejoin(final Conversation conversation) {
+        final Account account = getAccount();
+        synchronized (this.inProgressConferenceJoins) {
+            if (this.inProgressConferenceJoins.contains(conversation)) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": canceling muc self ping because join is already under way");
+                return;
+            }
+        }
+        synchronized (this.inProgressConferencePings) {
+            if (!this.inProgressConferencePings.add(conversation)) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": canceling muc self ping because ping is already under way");
+                return;
+            }
+        }
+        final Jid self = conversation.getMucOptions().getSelf().getFullJid();
+        final var future = getManager(PingManager.class).ping(self);
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(Iq result) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": ping to "
+                                        + self
+                                        + " came back fine");
+                        synchronized (MultiUserChatManager.this.inProgressConferencePings) {
+                            MultiUserChatManager.this.inProgressConferencePings.remove(
+                                    conversation);
+                        }
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        synchronized (MultiUserChatManager.this.inProgressConferencePings) {
+                            MultiUserChatManager.this.inProgressConferencePings.remove(
+                                    conversation);
+                        }
+                        if (throwable instanceof IqErrorException iqErrorException) {
+                            final var condition = iqErrorException.getErrorCondition();
+                            if (condition instanceof Condition.ServiceUnavailable
+                                    || condition instanceof Condition.FeatureNotImplemented
+                                    || condition instanceof Condition.ItemNotFound) {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        account.getJid().asBareJid()
+                                                + ": ping to "
+                                                + self
+                                                + " came back as ignorable error");
+                            } else {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        account.getJid().asBareJid()
+                                                + ": ping to "
+                                                + self
+                                                + " failed. attempting rejoin");
+                                join(conversation);
+                            }
+                        }
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> destroy(final Jid address) {
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var mucOwner = iq.addExtension(new MucOwner());
+        mucOwner.addExtension(new Destroy());
+        return Futures.transform(
+                connection.sendIqPacket(iq), result -> null, MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> setAffiliation(
+            final Conversation conversation, final Affiliation affiliation, Jid user) {
+        return setAffiliation(conversation, affiliation, Collections.singleton(user));
+    }
+
+    public ListenableFuture<Void> setAffiliation(
+            final Conversation conversation,
+            final Affiliation affiliation,
+            final Collection<Jid> users) {
+        final var address = conversation.getJid().asBareJid();
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var admin = iq.addExtension(new MucAdmin());
+        for (final var user : users) {
+            final var item = admin.addExtension(new Item());
+            item.setJid(user);
+            item.setAffiliation(affiliation);
+        }
+        return Futures.transform(
+                this.connection.sendIqPacket(iq),
+                response -> {
+                    // TODO figure out what this was meant to do
+                    // is this a work around for some servers not sending notifications when
+                    // changing the affiliation of people not in the room? this would explain this
+                    // firing only when getRole == None
+                    final var mucOptions = conversation.getMucOptions();
+                    for (final var user : users) {
+                        mucOptions.changeAffiliation(user, affiliation);
+                    }
+                    service.getAvatarService().clear(mucOptions);
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> setRole(final Jid address, final Role role, final String user) {
+        return setRole(address, role, Collections.singleton(user));
+    }
+
+    public ListenableFuture<Void> setRole(
+            final Jid address, final Role role, final Collection<String> users) {
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var admin = iq.addExtension(new MucAdmin());
+        for (final var user : users) {
+            final var item = admin.addExtension(new Item());
+            item.setNick(user);
+            item.setRole(role);
+        }
+        return Futures.transform(
+                this.connection.sendIqPacket(iq), response -> null, MoreExecutors.directExecutor());
+    }
+
+    public void setSubject(final Conversation conversation, final String subject) {
+        final var message = new Message();
+        message.setType(Message.Type.GROUPCHAT);
+        message.setTo(conversation.getJid().asBareJid());
+        message.addExtension(new Subject(subject));
+        connection.sendMessagePacket(message);
+    }
+
+    public void invite(final Conversation conversation, final Jid address) {
+        Log.d(
+                Config.LOGTAG,
+                conversation.getAccount().getJid().asBareJid()
+                        + ": inviting "
+                        + address
+                        + " to "
+                        + conversation.getJid().asBareJid());
+        final MucOptions.User user =
+                conversation.getMucOptions().findUserByRealJid(address.asBareJid());
+        if (user == null || user.getAffiliation() == Affiliation.OUTCAST) {
+            this.setAffiliation(conversation, Affiliation.NONE, address);
+        }
+
+        final var packet = new Message();
+        packet.setTo(conversation.getJid().asBareJid());
+        final var x = packet.addExtension(new MucUser());
+        final var invite = x.addExtension(new Invite());
+        invite.setTo(address.asBareJid());
+        connection.sendMessagePacket(packet);
+    }
+
+    public void directInvite(final Conversation conversation, final Jid address) {
+        final var message = new Message();
+        message.setTo(address);
+        final var directInvite = message.addExtension(new DirectInvite());
+        directInvite.setJid(conversation.getJid().asBareJid());
+        final var password = conversation.getMucOptions().getPassword();
+        if (password != null) {
+            directInvite.setPassword(password);
+        }
+        if (address.isFullJid()) {
+            message.addExtension(new NoStore());
+            message.addExtension(new NoCopy());
+        }
+        this.connection.sendMessagePacket(message);
+    }
+
+    public boolean isJoinInProgress(final Conversation conversation) {
+        synchronized (this.inProgressConferenceJoins) {
+            if (conversation.getMode() == Conversational.MODE_MULTI) {
+                final boolean inProgress = this.inProgressConferenceJoins.contains(conversation);
+                if (inProgress) {
+                    Log.d(
+                            Config.LOGTAG,
+                            getAccount().getJid().asBareJid()
+                                    + ": holding back message to group. join in progress");
+                }
+                return inProgress;
+            } else {
+                return false;
+            }
+        }
+    }
+
+    public void clearInProgress() {
+        synchronized (this.inProgressConferenceJoins) {
+            this.inProgressConferenceJoins.clear();
+        }
+        synchronized (this.inProgressConferencePings) {
+            this.inProgressConferencePings.clear();
+        }
+    }
+
+    public Jid getService() {
+        return Iterables.getFirst(this.getServices(), null);
+    }
+
+    public List<Jid> getServices() {
+        final var builder = new ImmutableList.Builder<Jid>();
+        for (final var entry : getManager(DiscoManager.class).getServerItems().entrySet()) {
+            final var value = entry.getValue();
+            if (value.getFeatureStrings().contains(Namespace.MUC)
+                    && value.hasIdentityWithCategoryAndType("conference", "text")
+                    && !value.getFeatureStrings().contains("jabber:iq:gateway")
+                    && !value.hasIdentityWithCategoryAndType("conference", "irc")) {
+                builder.add(entry.getKey());
+            }
+        }
+        return builder.build();
+    }
+
+    public static MucOptions.User itemToUser(
+            final Conversation conference,
+            im.conversations.android.xmpp.model.muc.Item item,
+            final Jid from) {
+        final var affiliation = item.getAffiliation();
+        final var role = item.getRole();
+        final var nick = item.getNick();
+        final Jid fullAddress;
+        if (from != null && from.isFullJid()) {
+            fullAddress = from;
+        } else if (Strings.isNullOrEmpty(nick)) {
+            fullAddress = null;
+        } else {
+            fullAddress = ofNick(conference, nick);
+        }
+        final Jid realJid = item.getAttributeAsJid("jid");
+        MucOptions.User user = new MucOptions.User(conference.getMucOptions(), fullAddress);
+        if (Jid.Invalid.isValid(realJid)) {
+            user.setRealJid(realJid);
+        }
+        user.setAffiliation(affiliation);
+        user.setRole(role);
+        return user;
+    }
+
+    private static Jid ofNick(final Conversation conversation, final String nick) {
+        try {
+            return conversation.getJid().withResource(nick);
+        } catch (final IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    private static Map<String, Object> modifyBestInteroperability(
+            final Map<String, Object> unmodified) {
+        final var builder = new ImmutableMap.Builder<String, Object>();
+        builder.putAll(unmodified);
+
+        if (unmodified.get("muc#roomconfig_moderatedroom") instanceof Boolean moderated) {
+            builder.put("members_by_default", !moderated);
+        }
+        if (unmodified.get("muc#roomconfig_allowpm") instanceof String allowPm) {
+            // ejabberd :-/
+            final boolean allow = "anyone".equals(allowPm);
+            builder.put("allow_private_messages", allow);
+            builder.put("allow_private_messages_from_visitors", allow ? "anyone" : "nobody");
+        }
+
+        if (unmodified.get("muc#roomconfig_allowinvites") instanceof Boolean allowInvites) {
+            // TODO check that this actually does something useful?
+            builder.put(
+                    "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", allowInvites);
+        }
+
+        return builder.buildOrThrow();
+    }
+
+    private static Map<String, Object> configWithName(
+            final Map<String, Object> unmodified, final String name) {
+        if (Strings.isNullOrEmpty(name)) {
+            return unmodified;
+        }
+        return new ImmutableMap.Builder<String, Object>()
+                .putAll(unmodified)
+                .put("muc#roomconfig_roomname", name)
+                .buildKeepingLast();
+    }
+
+    public static Map<String, Object> defaultGroupChatConfiguration() {
+        return new ImmutableMap.Builder<String, Object>()
+                .put("muc#roomconfig_persistentroom", true)
+                .put("muc#roomconfig_membersonly", true)
+                .put("muc#roomconfig_publicroom", false)
+                .put("muc#roomconfig_whois", "anyone")
+                .put("muc#roomconfig_changesubject", false)
+                .put("muc#roomconfig_allowinvites", false)
+                .put("muc#roomconfig_enablearchiving", true) // prosody
+                .put("mam", true) // ejabberd community
+                .put("muc#roomconfig_mam", true) // ejabberd saas
+                .buildOrThrow();
+    }
+
+    public static Map<String, Object> defaultChannelConfiguration() {
+        return new ImmutableMap.Builder<String, Object>()
+                .put("muc#roomconfig_persistentroom", true)
+                .put("muc#roomconfig_membersonly", false)
+                .put("muc#roomconfig_publicroom", true)
+                .put("muc#roomconfig_whois", "moderators")
+                .put("muc#roomconfig_changesubject", false)
+                .put("muc#roomconfig_enablearchiving", true) // prosody
+                .put("mam", true) // ejabberd community
+                .put("muc#roomconfig_mam", true) // ejabberd saas
+                .buildOrThrow();
+    }
 }

src/main/java/eu/siacs/conversations/xmpp/manager/NativeBookmarkManager.java πŸ”—

@@ -0,0 +1,172 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.collect.ImmutableMap;
+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 eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Bookmark;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.NodeConfiguration;
+import im.conversations.android.xmpp.model.bookmark2.Conference;
+import im.conversations.android.xmpp.model.bookmark2.Nick;
+import im.conversations.android.xmpp.model.bookmark2.Password;
+import im.conversations.android.xmpp.model.pubsub.Items;
+import im.conversations.android.xmpp.model.pubsub.event.Retract;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+public class NativeBookmarkManager extends AbstractBookmarkManager {
+
+    public NativeBookmarkManager(final XmppConnectionService service, XmppConnection connection) {
+        super(service, connection);
+    }
+
+    public void fetch() {
+        final var future = getManager(PepManager.class).fetchItems(Conference.class);
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Map<String, Conference> bookmarks) {
+                        final var builder = new ImmutableMap.Builder<Jid, Bookmark>();
+                        for (final var entry : bookmarks.entrySet()) {
+                            final Bookmark bookmark =
+                                    itemToBookmark(entry.getKey(), entry.getValue(), getAccount());
+                            if (bookmark == null) {
+                                continue;
+                            }
+                            builder.put(bookmark.getJid(), bookmark);
+                        }
+                        processBookmarksInitial(builder.buildKeepingLast(), true);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        Log.d(Config.LOGTAG, "Could not fetch bookmarks", throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public void handleItems(final Items items) {
+        this.handleItems(items.getItemMap(Conference.class));
+        this.handleRetractions(items.getRetractions());
+    }
+
+    private void handleRetractions(final Collection<Retract> retractions) {
+        final var account = getAccount();
+        for (final var retract : retractions) {
+            final Jid id = Jid.Invalid.getNullForInvalid(retract.getAttributeAsJid("id"));
+            if (id != null) {
+                account.removeBookmark(id);
+                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmark for " + id);
+                processDeletedBookmark(id);
+                service.updateConversationUi();
+            }
+        }
+    }
+
+    private void handleItems(final Map<String, Conference> items) {
+        final var account = getAccount();
+        for (final var item : items.entrySet()) {
+            final Bookmark bookmark = itemToBookmark(item.getKey(), item.getValue(), account);
+            if (bookmark == null) {
+                continue;
+            }
+            account.putBookmark(bookmark);
+            getManager(BookmarkManager.class).processModifiedBookmark(bookmark, true);
+            service.updateConversationUi();
+        }
+    }
+
+    public ListenableFuture<Void> publish(final Bookmark bookmark) {
+        final var address = bookmark.getJid();
+        final var name = bookmark.getBookmarkName();
+        final var nick = bookmark.getNick();
+        final String password = bookmark.getPassword();
+        final var itemId = address.toString();
+        final var conference = new Conference();
+        conference.setAutoJoin(bookmark.autojoin());
+        if (nick != null) {
+            conference.addExtension(new Nick()).setContent(nick);
+        }
+        if (name != null) {
+            conference.setConferenceName(name);
+        }
+        if (password != null) {
+            conference.addExtension(new Password()).setContent(password);
+        }
+        conference.addExtension(bookmark.getExtensions());
+        return Futures.transform(
+                getManager(PepManager.class)
+                        .publish(conference, itemId, NodeConfiguration.WHITELIST_MAX_ITEMS),
+                result -> null,
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> retract(final Jid address) {
+        final var itemId = address.toString();
+        return Futures.transform(
+                getManager(PepManager.class).retract(itemId, Namespace.BOOKMARKS2),
+                result -> null,
+                MoreExecutors.directExecutor());
+    }
+
+    private void deleteAllItems() {
+        final var account = getAccount();
+        final var previous = account.getBookmarkedJids();
+        account.setBookmarks(Collections.emptyMap());
+        processDeletedBookmarks(previous);
+    }
+
+    public void handleDelete() {
+        Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + ": deleted bookmarks node");
+        this.deleteAllItems();
+    }
+
+    public void handlePurge() {
+        Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + ": purged bookmarks");
+        this.deleteAllItems();
+    }
+
+    public boolean hasFeature() {
+        final var pep = getManager(PepManager.class);
+        final var disco = getManager(DiscoManager.class);
+        return pep.hasPublishOptions()
+                && pep.hasConfigNodeMax()
+                && disco.hasAccountFeature(Namespace.BOOKMARKS2_COMPAT);
+    }
+
+    private static Bookmark itemToBookmark(
+            final String id, final Conference conference, final Account account) {
+        if (id == null || conference == null) {
+            return null;
+        }
+        final var jid = Jid.Invalid.getNullForInvalid(Jid.ofOrInvalid(id));
+        if (jid == null || jid.isFullJid()) {
+            return null;
+        }
+        final Bookmark bookmark = new Bookmark(account, jid);
+
+        // TODO use proper API
+
+        bookmark.setBookmarkName(conference.getAttribute("name"));
+        bookmark.setAutojoin(conference.getAttributeAsBoolean("autojoin"));
+        bookmark.setNick(conference.findChildContent("nick"));
+        bookmark.setPassword(conference.findChildContent("password"));
+        final var extensions = conference.getExtensions();
+        if (extensions != null) {
+            bookmark.setExtensions(conference.getExtensions());
+        }
+        return bookmark;
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/PingManager.java πŸ”—

@@ -4,7 +4,9 @@ import android.content.Context;
 import androidx.annotation.NonNull;
 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 eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import im.conversations.android.xmpp.model.ping.Ping;
 import im.conversations.android.xmpp.model.stanza.Iq;
@@ -24,6 +26,12 @@ public class PingManager extends AbstractManager {
         }
     }
 
+    public ListenableFuture<Iq> ping(final Jid address) {
+        final var iq = new Iq(Iq.Type.GET, new Ping());
+        iq.setTo(address);
+        return this.connection.sendIqPacket(iq);
+    }
+
     public void ping(final Runnable runnable) {
         final var pingFuture = this.connection.sendIqPacket(new Iq(Iq.Type.GET, new Ping()));
         Futures.addCallback(

src/main/java/eu/siacs/conversations/xmpp/manager/PresenceManager.java πŸ”—

@@ -105,9 +105,7 @@ public class PresenceManager extends AbstractManager {
         presence.setAvailability(availability);
         presence.setStatus(message);
         if (pgpSignature != null) {
-            final var signed = new Signed();
-            signed.setContent(pgpSignature);
-            presence.addExtension(signed);
+            presence.addExtension(new Signed(pgpSignature));
         }
 
         final var lastActivity = service.getLastActivity();
@@ -127,8 +125,13 @@ public class PresenceManager extends AbstractManager {
     }
 
     public void available(final Jid to, final Extension... extensions) {
+        available(to, null, extensions);
+    }
+
+    public void available(final Jid to, final String message, final Extension... extensions) {
         final var presence = new Presence();
         presence.setTo(to);
+        presence.setStatus(message);
         for (final var extension : extensions) {
             presence.addExtension(extension);
         }

src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java πŸ”—

@@ -176,7 +176,7 @@ public class PubSubManager extends AbstractManager {
         final var isFromBare = from == null || from.isBareJid();
         final var node = items.getNode();
         if (connection.fromAccount(message) && Namespace.BOOKMARKS2.equals(node)) {
-            getManager(BookmarkManager.class).handleItems(items);
+            getManager(NativeBookmarkManager.class).handleItems(items);
             return;
         }
         if (connection.fromAccount(message) && Namespace.BOOKMARKS.equals(node)) {
@@ -205,7 +205,7 @@ public class PubSubManager extends AbstractManager {
         final var isFromBare = from == null || from.isBareJid();
         final var node = purge.getNode();
         if (connection.fromAccount(message) && Namespace.BOOKMARKS2.equals(node)) {
-            getManager(BookmarkManager.class).handlePurge();
+            getManager(NativeBookmarkManager.class).handlePurge();
         }
         if (isFromBare && Namespace.AVATAR_METADATA.equals(node)) {
             // purge (delete all items in a node) is functionally equivalent to delete
@@ -218,7 +218,7 @@ public class PubSubManager extends AbstractManager {
         final var isFromBare = from == null || from.isBareJid();
         final var node = delete.getNode();
         if (connection.fromAccount(message) && Namespace.BOOKMARKS2.equals(node)) {
-            getManager(BookmarkManager.class).handleDelete();
+            getManager(NativeBookmarkManager.class).handleDelete();
             return;
         }
         if (isFromBare && Namespace.AVATAR_METADATA.equals(node)) {

src/main/java/im/conversations/android/xmpp/model/conference/DirectInvite.java πŸ”—

@@ -0,0 +1,21 @@
+package im.conversations.android.xmpp.model.conference;
+
+import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement(name = "x")
+public class DirectInvite extends Extension {
+
+    public DirectInvite() {
+        super(DirectInvite.class);
+    }
+
+    public void setJid(final Jid jid) {
+        this.setAttribute("jid", jid);
+    }
+
+    public void setPassword(final String password) {
+        this.setAttribute("password", password);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/data/Data.java πŸ”—

@@ -1,8 +1,10 @@
 package im.conversations.android.xmpp.model.data;
 
+import android.util.Log;
 import com.google.common.base.CaseFormat;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.Iterables;
+import eu.siacs.conversations.Config;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 import java.util.Collection;
@@ -52,11 +54,14 @@ public class Data extends Extension {
         if (type != null) {
             field.setType(type);
         }
-        if (value instanceof Collection) {
-            for (final Object subValue : (Collection<?>) value) {
-                if (subValue instanceof String) {
+        if (value instanceof Collection<?> collection) {
+            Log.d(Config.LOGTAG, "submitting collection: " + collection);
+            for (final Object subValue : collection) {
+                if (subValue == null) {
+                    Log.d(Config.LOGTAG, "null value in the values for " + name);
+                } else if (subValue instanceof String s) {
                     final var valueExtension = field.addExtension(new Value());
-                    valueExtension.setContent((String) subValue);
+                    valueExtension.setContent(s);
                 } else {
                     throw new IllegalArgumentException(
                             String.format(
@@ -66,15 +71,15 @@ public class Data extends Extension {
             }
         } else {
             final var valueExtension = field.addExtension(new Value());
-            if (value instanceof String) {
-                valueExtension.setContent((String) value);
+            if (value instanceof String s) {
+                valueExtension.setContent(s);
             } else if (value instanceof Enum<?> e) {
                 valueExtension.setContent(
                         CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, e.toString()));
-            } else if (value instanceof Integer) {
-                valueExtension.setContent(String.valueOf(value));
-            } else if (value instanceof Boolean) {
-                valueExtension.setContent(Boolean.TRUE.equals(value) ? "1" : "0");
+            } else if (value instanceof Integer i) {
+                valueExtension.setContent(String.valueOf(i));
+            } else if (value instanceof Boolean b) {
+                valueExtension.setContent(Boolean.TRUE.equals(b) ? "1" : "0");
             } else {
                 throw new IllegalArgumentException(
                         String.format(
@@ -109,9 +114,11 @@ public class Data extends Extension {
             final var fieldName = existingField.getFieldName();
             final Object submittedValue = values.get(fieldName);
             if (submittedValue != null) {
+                Log.d(Config.LOGTAG, "submitting value " + fieldName + ": " + submittedValue);
                 submit.addField(fieldName, submittedValue);
             } else {
-                submit.addField(fieldName, existingField.getValues());
+                Log.d(Config.LOGTAG, "staying with default for: " + fieldName);
+                submit.addExtension(existingField);
             }
         }
         return submit;

src/main/java/im/conversations/android/xmpp/model/muc/History.java πŸ”—

@@ -1,5 +1,6 @@
 package im.conversations.android.xmpp.model.muc;
 
+import eu.siacs.conversations.generator.AbstractGenerator;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 
@@ -17,4 +18,8 @@ public class History extends Extension {
     public void setMaxStanzas(final int maxStanzas) {
         this.setAttribute("maxstanzas", maxStanzas);
     }
+
+    public void setSince(long timestamp) {
+        this.setAttribute("since", AbstractGenerator.getTimestamp(timestamp));
+    }
 }

src/main/java/im/conversations/android/xmpp/model/muc/Item.java πŸ”—

@@ -0,0 +1,54 @@
+package im.conversations.android.xmpp.model.muc;
+
+import android.util.Log;
+import com.google.common.base.Strings;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.Extension;
+import java.util.Locale;
+
+public abstract class Item extends Extension {
+
+    public Item(final Class<? extends Item> clazz) {
+        super(clazz);
+    }
+
+    public Affiliation getAffiliation() {
+        return affiliationOrNone(this.getAttribute("affiliation"));
+    }
+
+    public static Affiliation affiliationOrNone(final String affiliation) {
+        if (Strings.isNullOrEmpty(affiliation)) {
+            return Affiliation.NONE;
+        }
+        try {
+            return Affiliation.valueOf(affiliation.toUpperCase(Locale.ROOT));
+        } catch (final IllegalArgumentException e) {
+            return Affiliation.NONE;
+        }
+    }
+
+    public Role getRole() {
+        return roleOrNone(this.getAttribute("role"));
+    }
+
+    public static Role roleOrNone(final String role) {
+        if (Strings.isNullOrEmpty(role)) {
+            return Role.NONE;
+        }
+        try {
+            return Role.valueOf(role.toUpperCase(Locale.ROOT));
+        } catch (final IllegalArgumentException e) {
+            Log.d(Config.LOGTAG, "could not parse role " + role);
+            return Role.NONE;
+        }
+    }
+
+    public String getNick() {
+        return this.getAttribute("nick");
+    }
+
+    public Jid getJid() {
+        return this.getAttributeAsJid("jid");
+    }
+}

src/main/java/im/conversations/android/xmpp/model/muc/Password.java πŸ”—

@@ -0,0 +1,17 @@
+package im.conversations.android.xmpp.model.muc;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Password extends Extension {
+
+    public Password() {
+        super(Password.class);
+    }
+
+    public Password(final String password) {
+        this();
+        this.setContent(password);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/muc/admin/Item.java πŸ”—

@@ -0,0 +1,30 @@
+package im.conversations.android.xmpp.model.muc.admin;
+
+import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.Role;
+
+@XmlElement
+public class Item extends im.conversations.android.xmpp.model.muc.Item {
+
+    public Item() {
+        super(Item.class);
+    }
+
+    public void setAffiliation(final Affiliation affiliation) {
+        this.setAttribute("affiliation", affiliation);
+    }
+
+    public void setRole(final Role role) {
+        this.setAttribute("role", role);
+    }
+
+    public void setJid(final Jid jid) {
+        this.setAttribute("jid", jid);
+    }
+
+    public void setNick(String user) {
+        this.setAttribute("nick", user);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/muc/admin/MucAdmin.java πŸ”—

@@ -0,0 +1,17 @@
+package im.conversations.android.xmpp.model.muc.admin;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
+
+@XmlElement(name = "query")
+public class MucAdmin extends Extension {
+
+    public MucAdmin() {
+        super(MucAdmin.class);
+    }
+
+    public Collection<Item> getItems() {
+        return this.getExtensions(Item.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/muc/owner/MucOwner.java πŸ”—

@@ -0,0 +1,16 @@
+package im.conversations.android.xmpp.model.muc.owner;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.data.Data;
+
+@XmlElement(name = "query")
+public class MucOwner extends Extension {
+    public MucOwner() {
+        super(MucOwner.class);
+    }
+
+    public Data getConfiguration() {
+        return this.getExtension(Data.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/muc/user/Invite.java πŸ”—

@@ -0,0 +1,17 @@
+package im.conversations.android.xmpp.model.muc.user;
+
+import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Invite extends Extension {
+
+    public Invite() {
+        super(Invite.class);
+    }
+
+    public void setTo(final Jid to) {
+        this.setAttribute("to", to);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/muc/user/Item.java πŸ”—

@@ -1,58 +1,11 @@
 package im.conversations.android.xmpp.model.muc.user;
 
-import android.util.Log;
-
-import com.google.common.base.Strings;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.xmpp.Jid;
-
 import im.conversations.android.annotation.XmlElement;
-import im.conversations.android.xmpp.model.Extension;
-import im.conversations.android.xmpp.model.muc.Affiliation;
-import im.conversations.android.xmpp.model.muc.Role;
-
-import java.util.Locale;
 
 @XmlElement
-public class Item extends Extension {
-
+public class Item extends im.conversations.android.xmpp.model.muc.Item {
 
     public Item() {
         super(Item.class);
     }
-
-    public Affiliation getAffiliation() {
-        final var affiliation = this.getAttribute("affiliation");
-        if (Strings.isNullOrEmpty(affiliation)) {
-            return Affiliation.NONE;
-        }
-        try {
-            return Affiliation.valueOf(affiliation.toUpperCase(Locale.ROOT));
-        } catch (final IllegalArgumentException e) {
-            Log.d(Config.LOGTAG,"could not parse affiliation "+affiliation);
-            return Affiliation.NONE;
-        }
-    }
-
-    public Role getRole() {
-        final var role = this.getAttribute("role");
-        if (Strings.isNullOrEmpty(role)) {
-            return Role.NONE;
-        }
-        try {
-            return Role.valueOf(role.toUpperCase(Locale.ROOT));
-        } catch (final IllegalArgumentException e) {
-            Log.d(Config.LOGTAG,"could not parse role "+ role);
-            return Role.NONE;
-        }
-    }
-
-    public String getNick() {
-        return this.getAttribute("nick");
-    }
-
-    public Jid getJid() {
-        return this.getAttributeAsJid("jid");
-    }
 }

src/main/java/im/conversations/android/xmpp/model/pgp/Signed.java πŸ”—

@@ -1,15 +1,18 @@
 package im.conversations.android.xmpp.model.pgp;
 
 import eu.siacs.conversations.xml.Namespace;
-
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 
-@XmlElement(name = "x",namespace = Namespace.PGP_SIGNED)
+@XmlElement(name = "x", namespace = Namespace.PGP_SIGNED)
 public class Signed extends Extension {
 
-
     public Signed() {
         super(Signed.class);
     }
+
+    public Signed(final String signature) {
+        this();
+        this.setContent(signature);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/stanza/Iq.java πŸ”—

@@ -1,7 +1,6 @@
 package im.conversations.android.xmpp.model.stanza;
 
 import com.google.common.base.Strings;
-import eu.siacs.conversations.xml.Element;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.error.Error;
@@ -50,22 +49,6 @@ public class Iq extends Stanza {
         return super.isInvalid();
     }
 
-    // Legacy methods that need to be refactored:
-
-    public Element query() {
-        final Element query = findChild("query");
-        if (query != null) {
-            return query;
-        }
-        return addChild("query");
-    }
-
-    public Element query(final String xmlns) {
-        final Element query = query();
-        query.setAttribute("xmlns", xmlns);
-        return query();
-    }
-
     public Iq generateResponse(final Iq.Type type) {
         final var packet = new Iq(type);
         packet.setTo(this.getFrom());

src/main/java/im/conversations/android/xmpp/processor/AccountStateProcessor.java πŸ”—

@@ -5,12 +5,10 @@ import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
 import android.util.Log;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.http.ServiceOutageStatus;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.XmppConnection;
-import java.util.ArrayList;
-import java.util.List;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
@@ -48,7 +46,7 @@ public class AccountStateProcessor extends XmppConnection.Delegate
                 this.service.databaseBackend.updateAccount(account);
             }
             this.service.getMessageArchiveService().executePendingQueries(account);
-            if (connection != null && connection.getFeatures().csi()) {
+            if (this.connection.getFeatures().csi()) {
                 if (this.service.checkListeners()) {
                     Log.d(Config.LOGTAG, account.getJid().asBareJid() + " sending csi//inactive");
                     connection.sendInactive();
@@ -57,36 +55,14 @@ public class AccountStateProcessor extends XmppConnection.Delegate
                     connection.sendActive();
                 }
             }
-            List<Conversation> conversations = this.service.getConversations();
-            for (Conversation conversation : conversations) {
-                final boolean inProgressJoin;
-                synchronized (account.inProgressConferenceJoins) {
-                    inProgressJoin = account.inProgressConferenceJoins.contains(conversation);
-                }
-                final boolean pendingJoin;
-                synchronized (account.pendingConferenceJoins) {
-                    pendingJoin = account.pendingConferenceJoins.contains(conversation);
-                }
-                if (conversation.getAccount() == account && !pendingJoin && !inProgressJoin) {
+            final var mucManager = getManager(MultiUserChatManager.class);
+            final var conversations = this.service.getConversations();
+            for (final var conversation : conversations) {
+                final boolean inProgressJoin = mucManager.isJoinInProgress(conversation);
+                if (conversation.getAccount() == account && !inProgressJoin) {
                     this.service.sendUnsentMessages(conversation);
                 }
             }
-            final List<Conversation> pendingLeaves;
-            synchronized (account.pendingConferenceLeaves) {
-                pendingLeaves = new ArrayList<>(account.pendingConferenceLeaves);
-                account.pendingConferenceLeaves.clear();
-            }
-            for (Conversation conversation : pendingLeaves) {
-                this.service.leaveMuc(conversation);
-            }
-            final List<Conversation> pendingJoins;
-            synchronized (account.pendingConferenceJoins) {
-                pendingJoins = new ArrayList<>(account.pendingConferenceJoins);
-                account.pendingConferenceJoins.clear();
-            }
-            for (Conversation conversation : pendingJoins) {
-                this.service.joinMuc(conversation);
-            }
             this.service.scheduleWakeUpCall(
                     Config.PING_MAX_INTERVAL * 1000L, account.getUuid().hashCode());
         } else if (account.getStatus() == Account.State.OFFLINE

src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java πŸ”—

@@ -12,12 +12,11 @@ import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.manager.BookmarkManager;
 import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
-import eu.siacs.conversations.xmpp.manager.LegacyBookmarkManager;
 import eu.siacs.conversations.xmpp.manager.MessageDisplayedSynchronizationManager;
+import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 import eu.siacs.conversations.xmpp.manager.NickManager;
 import eu.siacs.conversations.xmpp.manager.OfflineMessagesManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
-import eu.siacs.conversations.xmpp.manager.PrivateStorageManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 
 public class BindProcessor extends XmppConnection.Delegate implements Runnable {
@@ -63,26 +62,12 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
         }
 
         getManager(RosterManager.class).clearPresences();
-        synchronized (account.inProgressConferenceJoins) {
-            account.inProgressConferenceJoins.clear();
-        }
-        synchronized (account.inProgressConferencePings) {
-            account.inProgressConferencePings.clear();
-        }
+        getManager(MultiUserChatManager.class).clearInProgress();
         service.getJingleConnectionManager().notifyRebound(account);
         service.getQuickConversationsService().considerSyncBackground(false);
 
         getManager(RosterManager.class).request();
-
-        if (getManager(BookmarkManager.class).hasFeature()) {
-            getManager(BookmarkManager.class).fetch();
-        } else if (getManager(LegacyBookmarkManager.class).hasConversion()) {
-            Log.d(
-                    Config.LOGTAG,
-                    account.getJid() + ": not fetching bookmarks. waiting for server to push");
-        } else {
-            getManager(PrivateStorageManager.class).fetchBookmarks();
-        }
+        getManager(BookmarkManager.class).request();
 
         if (features.mds()) {
             getManager(MessageDisplayedSynchronizationManager.class).fetch();

src/main/res/values/strings.xml πŸ”—

@@ -93,7 +93,7 @@
     <string name="send_message_to_x">Send message to %s</string>
     <string name="send_encrypted_message">Send encrypted message</string>
     <string name="send_omemo_x509_message">Send v\\OMEMO encrypted message</string>
-    <string name="your_nick_has_been_changed">New nickname in use</string>
+    <string name="your_nick_has_been_changed">Your nickname has been changed</string>
     <string name="send_unencrypted">Send clear text</string>
     <string name="decryption_failed">Decryption failed. Maybe you don’t have the proper private key.</string>
     <string name="openkeychain_required">OpenKeychain</string>