add stub managers for pub sub related events

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/entities/Account.java                     |  31 
src/main/java/eu/siacs/conversations/entities/Bookmark.java                    |  25 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                 |  10 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                |   2 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java       |  99 
src/main/java/eu/siacs/conversations/xmpp/IqErrorException.java                |  35 
src/main/java/eu/siacs/conversations/xmpp/IqErrorResponseException.java        |  33 
src/main/java/eu/siacs/conversations/xmpp/Managers.java                        |  16 
src/main/java/eu/siacs/conversations/xmpp/PreconditionNotMetException.java     |  16 
src/main/java/eu/siacs/conversations/xmpp/PubSubErrorException.java            |  19 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                  |  39 
src/main/java/eu/siacs/conversations/xmpp/manager/AbstractBookmarkManager.java |  62 
src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java           |  15 
src/main/java/eu/siacs/conversations/xmpp/manager/AxolotlManager.java          |  15 
src/main/java/eu/siacs/conversations/xmpp/manager/BookmarkManager.java         |  95 
src/main/java/eu/siacs/conversations/xmpp/manager/LegacyBookmarkManager.java   |  15 
src/main/java/eu/siacs/conversations/xmpp/manager/NickManager.java             |  31 
src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java              |  53 
src/main/java/eu/siacs/conversations/xmpp/manager/PrivateStorageManager.java   |  55 
src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java           | 349 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java       |   9 
21 files changed, 856 insertions(+), 168 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Account.java 🔗

@@ -20,7 +20,6 @@ import eu.siacs.conversations.crypto.sasl.HashedTokenSha512;
 import eu.siacs.conversations.crypto.sasl.SaslMechanism;
 import eu.siacs.conversations.http.ServiceOutageStatus;
 import eu.siacs.conversations.services.AvatarService;
-import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.utils.XmppUri;
@@ -96,8 +95,6 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     protected boolean online = false;
     private String rosterVersion;
     private String displayName = null;
-    private AxolotlService axolotlService = null;
-    private PgpDecryptionService pgpDecryptionService = null;
     private XmppConnection xmppConnection = null;
     private long mEndGracePeriod = 0L;
     private final Map<Jid, Bookmark> bookmarks = new HashMap<>();
@@ -233,11 +230,11 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public boolean hasPendingPgpIntent(Conversation conversation) {
-        return pgpDecryptionService != null && pgpDecryptionService.hasPendingIntent(conversation);
+        return getPgpDecryptionService().hasPendingIntent(conversation);
     }
 
     public boolean isPgpDecryptionServiceConnected() {
-        return pgpDecryptionService != null && pgpDecryptionService.isConnected();
+        return getPgpDecryptionService().isConnected();
     }
 
     public boolean setShowErrorNotification(boolean newValue) {
@@ -285,11 +282,12 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         final Jid prev = this.jid != null ? this.jid.asBareJid() : null;
         final boolean changed = prev == null || (next != null && !prev.equals(next.asBareJid()));
         if (changed) {
-            final AxolotlService oldAxolotlService = this.axolotlService;
+            final AxolotlService oldAxolotlService = xmppConnection.getAxolotlService();
+            // TODO check that changing JID and recreating the AxolotlService still works
             if (oldAxolotlService != null) {
                 oldAxolotlService.destroy();
                 this.jid = next;
-                this.axolotlService = oldAxolotlService.makeNew();
+                xmppConnection.setAxolotlService(oldAxolotlService.makeNew());
             }
         }
         this.jid = next;
@@ -545,18 +543,11 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public AxolotlService getAxolotlService() {
-        return axolotlService;
-    }
-
-    public void initAccountServices(final XmppConnectionService context) {
-        this.xmppConnection = new XmppConnection(this, context);
-        this.axolotlService = new AxolotlService(this, context);
-        this.pgpDecryptionService = new PgpDecryptionService(context);
-        this.xmppConnection.addOnAdvancedStreamFeaturesAvailableListener(axolotlService);
+        return this.xmppConnection.getAxolotlService();
     }
 
     public PgpDecryptionService getPgpDecryptionService() {
-        return this.pgpDecryptionService;
+        return this.xmppConnection.getPgpDecryptionService();
     }
 
     public XmppConnection getXmppConnection() {
@@ -739,9 +730,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
 
     private List<XmppUri.Fingerprint> getFingerprints() {
         ArrayList<XmppUri.Fingerprint> fingerprints = new ArrayList<>();
-        if (axolotlService == null) {
-            return fingerprints;
-        }
+        final var axolotlService = getAxolotlService();
         fingerprints.add(
                 new XmppUri.Fingerprint(
                         XmppUri.FingerprintType.OMEMO,
@@ -811,6 +800,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         return false;
     }
 
+    public void setXmppConnection(final XmppConnection connection) {
+        this.xmppConnection = connection;
+    }
+
     public enum State {
         DISABLED(false, false),
         LOGGED_OUT(false, false),

src/main/java/eu/siacs/conversations/entities/Bookmark.java 🔗

@@ -12,7 +12,6 @@ import eu.siacs.conversations.xml.Namespace;
 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.pubsub.PubSub;
 import java.lang.ref.WeakReference;
 import java.util.Collections;
 import java.util.HashMap;
@@ -41,6 +40,7 @@ public class Bookmark extends Element implements ListItem {
 
     public static Map<Jid, Bookmark> parseFromStorage(
             final Storage storage, final Account account) {
+        // TODO refactor to use extensions. get rid of the 'old' handling
         if (storage == null) {
             return Collections.emptyMap();
         }
@@ -61,26 +61,6 @@ public class Bookmark extends Element implements ListItem {
         return bookmarks;
     }
 
-    public static Map<Jid, Bookmark> parseFromPubSub(final PubSub pubSub, final Account account) {
-        if (pubSub == null) {
-            return Collections.emptyMap();
-        }
-        final var items = pubSub.getItems();
-        if (items == null || !Namespace.BOOKMARKS2.equals(items.getNode())) {
-            return Collections.emptyMap();
-        }
-        final Map<Jid, Bookmark> bookmarks = new HashMap<>();
-        for (final var item : items.getItemMap(Conference.class).entrySet()) {
-            final Bookmark bookmark =
-                    Bookmark.parseFromItem(item.getKey(), item.getValue(), account);
-            if (bookmark == null) {
-                continue;
-            }
-            bookmarks.put(bookmark.jid, bookmark);
-        }
-        return bookmarks;
-    }
-
     public static Bookmark parse(Element element, Account account) {
         Bookmark bookmark = new Bookmark(account);
         bookmark.setAttributes(element.getAttributes());
@@ -103,6 +83,9 @@ public class Bookmark extends Element implements ListItem {
         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"));

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -37,6 +37,7 @@ 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.PubSubManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.model.Extension;
@@ -321,7 +322,7 @@ public class MessageParser extends AbstractParser
                 }
                 final var storage = items.getFirstItem(Storage.class);
                 final Map<Jid, Bookmark> bookmarks = Bookmark.parseFromStorage(storage, account);
-                mXmppConnectionService.processBookmarksInitial(account, bookmarks, true);
+                // mXmppConnectionService.processBookmarksInitial(account, bookmarks, true);
                 Log.d(
                         Config.LOGTAG,
                         account.getJid().asBareJid() + ": processing bookmark PEP event");
@@ -351,7 +352,7 @@ public class MessageParser extends AbstractParser
                     Log.d(
                             Config.LOGTAG,
                             account.getJid().asBareJid() + ": deleted bookmark for " + id);
-                    mXmppConnectionService.processDeletedBookmark(account, id);
+                    // mXmppConnectionService.processDeletedBookmark(account, id);
                     mXmppConnectionService.updateConversationUi();
                 }
             }
@@ -398,7 +399,7 @@ public class MessageParser extends AbstractParser
     private void deleteAllBookmarks(final Account account) {
         final var previous = account.getBookmarkedJids();
         account.setBookmarks(Collections.emptyMap());
-        mXmppConnectionService.processDeletedBookmarks(account, previous);
+        // mXmppConnectionService.processDeletedBookmarks(account, previous);
     }
 
     private void setNick(final Account account, final Jid user, final String nick) {
@@ -1410,6 +1411,9 @@ public class MessageParser extends AbstractParser
             // end no body
         }
 
+        if (original.hasExtension(Event.class)) {
+            getManager(PubSubManager.class).handleEvent(original);
+        }
         final var event = original.getExtension(Event.class);
         if (event != null && Jid.Invalid.hasValidFrom(original) && original.getFrom().isBareJid()) {
             final var action = event.getAction();

src/main/java/eu/siacs/conversations/parser/PresenceParser.java 🔗

@@ -50,6 +50,7 @@ public class PresenceParser extends AbstractParser
                         ? null
                         : mXmppConnectionService.find(account, packet.getFrom().asBareJid());
         if (conversation == null) {
+            Log.d(Config.LOGTAG, "conversation not found for parsing conference presence");
             return;
         }
         final MucOptions mucOptions = conversation.getMucOptions();
@@ -490,6 +491,7 @@ public class PresenceParser extends AbstractParser
 
     @Override
     public void accept(final im.conversations.android.xmpp.model.stanza.Presence packet) {
+        // Log.d(Config.LOGTAG,"<--"+packet);
         if (packet.hasChild("x", Namespace.MUC_USER)) {
             this.parseConferencePresence(packet);
         } else if (packet.hasChild("x", "http://jabber.org/protocol/muc")) {

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -121,7 +121,6 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.LocalizedContent;
 import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.IqErrorResponseException;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnContactStatusChanged;
 import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
@@ -144,13 +143,12 @@ import eu.siacs.conversations.xmpp.manager.RosterManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import eu.siacs.conversations.xmpp.pep.PublishOptions;
 import im.conversations.android.xmpp.Entity;
+import im.conversations.android.xmpp.IqErrorException;
 import im.conversations.android.xmpp.model.avatar.Metadata;
-import im.conversations.android.xmpp.model.bookmark.Storage;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.mds.Displayed;
 import im.conversations.android.xmpp.model.pubsub.PubSub;
 import im.conversations.android.xmpp.model.stanza.Iq;
-import im.conversations.android.xmpp.model.storage.PrivateStorage;
 import im.conversations.android.xmpp.model.up.Push;
 import java.io.File;
 import java.security.Security;
@@ -366,6 +364,7 @@ public class XmppConnectionService extends Service {
 
                 @Override
                 public void onStatusChanged(final Account account) {
+                    Log.d(Config.LOGTAG, "begin onStatusChanged()");
                     final var status = account.getStatus();
                     if (ServiceOutageStatus.isPossibleOutage(status)) {
                         fetchServiceOutageStatus(account);
@@ -499,6 +498,7 @@ public class XmppConnectionService extends Service {
                         }
                     }
                     getNotificationService().updateErrorNotification();
+                    Log.d(Config.LOGTAG, "end onStatusChanged()");
                 }
             };
 
@@ -1843,15 +1843,13 @@ public class XmppConnectionService extends Service {
 
     public XmppConnection createConnection(final Account account) {
         final XmppConnection connection = new XmppConnection(account, this);
+        // TODO move status listener into final variable in XmppConnection
         connection.setOnStatusChangedListener(this.statusListener);
         connection.setOnJinglePacketReceivedListener((mJingleConnectionManager::deliverPacket));
+        // TODO move MessageAck into final Processor into XmppConnection
         connection.setOnMessageAcknowledgeListener(this.mOnMessageAcknowledgedListener);
         connection.addOnAdvancedStreamFeaturesAvailableListener(this.mMessageArchiveService);
         connection.addOnAdvancedStreamFeaturesAvailableListener(this.mAvatarService);
-        AxolotlService axolotlService = account.getAxolotlService();
-        if (axolotlService != null) {
-            connection.addOnAdvancedStreamFeaturesAvailableListener(axolotlService);
-        }
         return connection;
     }
 
@@ -2137,44 +2135,6 @@ public class XmppConnectionService extends Service {
                 });
     }
 
-    public void fetchBookmarks(final Account account) {
-        final Iq iqPacket = new Iq(Iq.Type.GET);
-        iqPacket.addExtension(new PrivateStorage()).addExtension(new Storage());
-        final Consumer<Iq> callback =
-                (response) -> {
-                    if (response.getType() == Iq.Type.RESULT) {
-                        final var privateStorage = response.getExtension(PrivateStorage.class);
-                        if (privateStorage == null) {
-                            return;
-                        }
-                        final var bookmarkStorage = privateStorage.getExtension(Storage.class);
-                        Map<Jid, Bookmark> bookmarks =
-                                Bookmark.parseFromStorage(bookmarkStorage, account);
-                        processBookmarksInitial(account, bookmarks, false);
-                    } else {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid() + ": could not fetch bookmarks");
-                    }
-                };
-        sendIqPacket(account, iqPacket, callback);
-    }
-
-    public void fetchBookmarks2(final Account account) {
-        final Iq retrieve = mIqGenerator.retrieveBookmarks();
-        sendIqPacket(
-                account,
-                retrieve,
-                (response) -> {
-                    if (response.getType() == Iq.Type.RESULT) {
-                        final var pubsub = response.getExtension(PubSub.class);
-                        final Map<Jid, Bookmark> bookmarks =
-                                Bookmark.parseFromPubSub(pubsub, account);
-                        processBookmarksInitial(account, bookmarks, true);
-                    }
-                });
-    }
-
     public void fetchMessageDisplayedSynchronization(final Account account) {
         Log.d(Config.LOGTAG, account.getJid() + ": retrieve mds");
         final var retrieve = mIqGenerator.retrieveMds();
@@ -2252,43 +2212,7 @@ public class XmppConnectionService extends Service {
         return true;
     }
 
-    public void processBookmarksInitial(
-            final Account account, final Map<Jid, Bookmark> bookmarks, final boolean pep) {
-        final Set<Jid> previousBookmarks = account.getBookmarkedJids();
-        for (final Bookmark bookmark : bookmarks.values()) {
-            previousBookmarks.remove(bookmark.getJid().asBareJid());
-            processModifiedBookmark(bookmark, pep);
-        }
-        if (pep) {
-            processDeletedBookmarks(account, previousBookmarks);
-        }
-        account.setBookmarks(bookmarks);
-    }
-
-    public void processDeletedBookmarks(final Account account, final Collection<Jid> bookmarks) {
-        Log.d(
-                Config.LOGTAG,
-                account.getJid().asBareJid()
-                        + ": "
-                        + bookmarks.size()
-                        + " bookmarks have been removed");
-        for (final Jid bookmark : bookmarks) {
-            processDeletedBookmark(account, bookmark);
-        }
-    }
-
-    public void processDeletedBookmark(final Account account, final Jid jid) {
-        final Conversation conversation = find(account, jid);
-        if (conversation == null) {
-            return;
-        }
-        Log.d(
-                Config.LOGTAG,
-                account.getJid().asBareJid() + ": archiving MUC " + jid + " after PEP update");
-        archiveConversation(conversation, false);
-    }
-
-    private void processModifiedBookmark(final Bookmark bookmark, final boolean pep) {
+    public void processModifiedBookmark(final Bookmark bookmark, final boolean pep) {
         final Account account = bookmark.getAccount();
         Conversation conversation = find(bookmark);
         if (conversation != null) {
@@ -2407,6 +2331,7 @@ public class XmppConnectionService extends Service {
     private void pushBookmarksPrivateXml(Account account) {
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": pushing bookmarks via private xml");
         final Iq iqPacket = new Iq(Iq.Type.SET);
+        // TODO we have extensions for that
         Element query = iqPacket.query("jabber:iq:private");
         Element storage = query.addChild("storage", "storage:bookmarks");
         for (final Bookmark bookmark : account.getBookmarks()) {
@@ -2531,8 +2456,7 @@ public class XmppConnectionService extends Service {
                         }
                         Log.d(Config.LOGTAG, "restoring roster...");
                         for (final Account account : accounts) {
-                            account.initAccountServices(
-                                    this); // roster needs to be loaded at this stage
+                            account.setXmppConnection(createConnection(account));
                             account.getXmppConnection().getManager(RosterManager.class).restore();
                         }
                         getBitmapCache().evictAll();
@@ -2989,7 +2913,7 @@ public class XmppConnectionService extends Service {
         archiveConversation(conversation, true);
     }
 
-    private void archiveConversation(
+    public void archiveConversation(
             Conversation conversation, final boolean maySynchronizeWithBookmarks) {
         getNotificationService().clear(conversation);
         conversation.setStatus(Conversation.STATUS_ARCHIVED);
@@ -3034,7 +2958,7 @@ public class XmppConnectionService extends Service {
     }
 
     public void createAccount(final Account account) {
-        account.initAccountServices(this);
+        account.setXmppConnection(createConnection(account));
         databaseBackend.createAccount(account);
         if (CallIntegration.hasSystemFeature(this)) {
             CallIntegrationConnectionService.togglePhoneAccountAsync(this, account);
@@ -4450,8 +4374,7 @@ public class XmppConnectionService extends Service {
                                     account.getJid().asBareJid()
                                             + ": received timeout waiting for conference"
                                             + " configuration fetch");
-                        } else if (throwable
-                                instanceof IqErrorResponseException errorResponseException) {
+                        } else if (throwable instanceof IqErrorException errorResponseException) {
                             if (callback != null) {
                                 callback.onFetchFailed(
                                         conversation,

src/main/java/eu/siacs/conversations/xmpp/IqErrorException.java 🔗

@@ -0,0 +1,35 @@
+package im.conversations.android.xmpp;
+
+import com.google.common.base.Strings;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
+import im.conversations.android.xmpp.model.stanza.Iq;
+
+public class IqErrorException extends Exception {
+
+    private final Iq response;
+
+    public IqErrorException(Iq response) {
+        super(getErrorText(response));
+        this.response = response;
+    }
+
+    public Error getError() {
+        return this.response.getError();
+    }
+
+    private static String getErrorText(final Iq response) {
+        final var error = response.getError();
+        final var text = error == null ? null : error.getText();
+        final var textContent = text == null ? null : text.getContent();
+        if (Strings.isNullOrEmpty(textContent)) {
+            final var condition = error == null ? null : error.getExtension(Condition.class);
+            return condition == null ? null : condition.getName();
+        }
+        return textContent;
+    }
+
+    public Iq getResponse() {
+        return this.response;
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/IqErrorResponseException.java 🔗

@@ -1,33 +0,0 @@
-package eu.siacs.conversations.xmpp;
-
-import im.conversations.android.xmpp.model.stanza.Iq;
-
-public class IqErrorResponseException extends Exception {
-
-    private final Iq response;
-
-    public IqErrorResponseException(final Iq response) {
-        super(message(response));
-        this.response = response;
-    }
-
-    public Iq getResponse() {
-        return this.response;
-    }
-
-    public static String message(final Iq iq) {
-        final var error = iq.getError();
-        if (error == null) {
-            return "missing error element in response";
-        }
-        final var text = error.getTextAsString();
-        if (text != null) {
-            return text;
-        }
-        final var condition = error.getCondition();
-        if (condition != null) {
-            return condition.getName();
-        }
-        return "no condition attached to error";
-    }
-}

src/main/java/eu/siacs/conversations/xmpp/Managers.java 🔗

@@ -4,12 +4,20 @@ import com.google.common.collect.ClassToInstanceMap;
 import com.google.common.collect.ImmutableClassToInstanceMap;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.manager.AbstractManager;
+import eu.siacs.conversations.xmpp.manager.AvatarManager;
+import eu.siacs.conversations.xmpp.manager.AxolotlManager;
 import eu.siacs.conversations.xmpp.manager.BlockingManager;
+import eu.siacs.conversations.xmpp.manager.BookmarkManager;
 import eu.siacs.conversations.xmpp.manager.CarbonsManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import eu.siacs.conversations.xmpp.manager.EntityTimeManager;
+import eu.siacs.conversations.xmpp.manager.LegacyBookmarkManager;
+import eu.siacs.conversations.xmpp.manager.NickManager;
+import eu.siacs.conversations.xmpp.manager.PepManager;
 import eu.siacs.conversations.xmpp.manager.PingManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
+import eu.siacs.conversations.xmpp.manager.PrivateStorageManager;
+import eu.siacs.conversations.xmpp.manager.PubSubManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import eu.siacs.conversations.xmpp.manager.UnifiedPushManager;
 
@@ -22,12 +30,20 @@ public class Managers {
     public static ClassToInstanceMap<AbstractManager> get(
             final XmppConnectionService context, final XmppConnection connection) {
         return new ImmutableClassToInstanceMap.Builder<AbstractManager>()
+                .put(AvatarManager.class, new AvatarManager(context, connection))
+                .put(AxolotlManager.class, new AxolotlManager(context, connection))
                 .put(BlockingManager.class, new BlockingManager(context, connection))
+                .put(BookmarkManager.class, new BookmarkManager(context, connection))
                 .put(CarbonsManager.class, new CarbonsManager(context, connection))
                 .put(DiscoManager.class, new DiscoManager(context, connection))
                 .put(EntityTimeManager.class, new EntityTimeManager(context, connection))
+                .put(LegacyBookmarkManager.class, new LegacyBookmarkManager(context, connection))
+                .put(NickManager.class, new NickManager(context, connection))
+                .put(PepManager.class, new PepManager(context, connection))
                 .put(PingManager.class, new PingManager(context, connection))
                 .put(PresenceManager.class, new PresenceManager(context, connection))
+                .put(PrivateStorageManager.class, new PrivateStorageManager(context, connection))
+                .put(PubSubManager.class, new PubSubManager(context, connection))
                 .put(RosterManager.class, new RosterManager(context, connection))
                 .put(UnifiedPushManager.class, new UnifiedPushManager(context, connection))
                 .build();

src/main/java/eu/siacs/conversations/xmpp/PreconditionNotMetException.java 🔗

@@ -0,0 +1,16 @@
+package im.conversations.android.xmpp;
+
+import im.conversations.android.xmpp.model.pubsub.error.PubSubError;
+import im.conversations.android.xmpp.model.stanza.Iq;
+
+public class PreconditionNotMetException extends PubSubErrorException {
+
+    public PreconditionNotMetException(final Iq response) {
+        super(response);
+        if (this.pubSubError instanceof PubSubError.PreconditionNotMet) {
+            return;
+        }
+        throw new AssertionError(
+                "This exception should only be constructed for PreconditionNotMet errors");
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/PubSubErrorException.java 🔗

@@ -0,0 +1,19 @@
+package im.conversations.android.xmpp;
+
+import im.conversations.android.xmpp.model.pubsub.error.PubSubError;
+import im.conversations.android.xmpp.model.stanza.Iq;
+
+public class PubSubErrorException extends IqErrorException {
+
+    protected final PubSubError pubSubError;
+
+    public PubSubErrorException(Iq response) {
+        super(response);
+        final var error = response.getError();
+        final var pubSubError = error == null ? null : error.getExtension(PubSubError.class);
+        if (pubSubError == null) {
+            throw new AssertionError("This exception should only be constructed for PubSubErrors");
+        }
+        this.pubSubError = pubSubError;
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -33,6 +33,7 @@ import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.PgpDecryptionService;
 import eu.siacs.conversations.crypto.XmppDomainVerifier;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.sasl.ChannelBinding;
@@ -77,6 +78,7 @@ import eu.siacs.conversations.xmpp.manager.CarbonsManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import eu.siacs.conversations.xmpp.manager.PingManager;
 import im.conversations.android.xmpp.Entity;
+import im.conversations.android.xmpp.IqErrorException;
 import im.conversations.android.xmpp.model.AuthenticationFailure;
 import im.conversations.android.xmpp.model.AuthenticationRequest;
 import im.conversations.android.xmpp.model.AuthenticationStreamFeature;
@@ -200,6 +202,8 @@ public class XmppConnection implements Runnable {
     private final Consumer<Presence> presenceListener;
     private final Consumer<Iq> unregisteredIqListener;
     private final Consumer<im.conversations.android.xmpp.model.stanza.Message> messageListener;
+    private AxolotlService axolotlService;
+    private final PgpDecryptionService pgpDecryptionService;
     private OnStatusChanged statusListener = null;
     private final Runnable bindListener;
     private OnMessageAcknowledged acknowledgedListener = null;
@@ -226,6 +230,8 @@ public class XmppConnection implements Runnable {
         this.messageListener = new MessageParser(service, this);
         this.bindListener = new BindProcessor(service, this);
         this.managers = Managers.get(service, this);
+        this.setAxolotlService(new AxolotlService(account, service));
+        this.pgpDecryptionService = new PgpDecryptionService(service);
     }
 
     private static void fixResource(final Context context, final Account account) {
@@ -277,7 +283,13 @@ public class XmppConnection implements Runnable {
             }
         }
         if (statusListener != null) {
-            statusListener.onStatusChanged(account);
+            try {
+                statusListener.onStatusChanged(account);
+            } catch (final Exception e) {
+                Log.d(Config.LOGTAG, "error executing shit", e);
+            }
+        } else {
+            Log.d(Config.LOGTAG, "status changed listener was null");
         }
     }
 
@@ -2520,7 +2532,7 @@ public class XmppConnection implements Runnable {
                     switch (type) {
                         case RESULT -> settable.set(response);
                         case TIMEOUT -> settable.setException(new TimeoutException());
-                        default -> settable.setException(new IqErrorResponseException(response));
+                        default -> settable.setException(new IqErrorException(response));
                     }
                 });
         return settable;
@@ -2893,6 +2905,29 @@ public class XmppConnection implements Runnable {
                 || from.equals(account);
     }
 
+    public boolean fromAccount(final Stanza stanza) {
+        final var account = getAccount().getJid();
+        final Jid from = stanza.getFrom();
+        return from == null || from.asBareJid().equals(account.asBareJid());
+    }
+
+    public AxolotlService getAxolotlService() {
+        return this.axolotlService;
+    }
+
+    public PgpDecryptionService getPgpDecryptionService() {
+        return this.pgpDecryptionService;
+    }
+
+    public void setAxolotlService(AxolotlService axolotlService) {
+        final var current = this.axolotlService;
+        if (current != null) {
+            this.advancedStreamFeaturesLoadedListeners.remove(current);
+        }
+        this.axolotlService = axolotlService;
+        this.addOnAdvancedStreamFeaturesAvailableListener(axolotlService);
+    }
+
     private class MyKeyManager implements X509KeyManager {
         @Override
         public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) {

src/main/java/eu/siacs/conversations/xmpp/manager/AbstractBookmarkManager.java 🔗

@@ -0,0 +1,62 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.util.Log;
+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.services.XmppConnectionService;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+public class AbstractBookmarkManager extends AbstractManager {
+
+    private final XmppConnectionService service;
+
+    protected AbstractBookmarkManager(
+            final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+        this.service = service;
+    }
+
+    // TODO rename to setBookmarks?
+    public void processBookmarksInitial(final Map<Jid, Bookmark> bookmarks, final boolean pep) {
+        final var account = getAccount();
+        // TODO we can internalize this getBookmarkedJid
+        final Set<Jid> previousBookmarks = account.getBookmarkedJids();
+        for (final Bookmark bookmark : bookmarks.values()) {
+            previousBookmarks.remove(bookmark.getJid().asBareJid());
+            service.processModifiedBookmark(bookmark, pep);
+        }
+        if (pep) {
+            this.processDeletedBookmarks(account, previousBookmarks);
+        }
+        account.setBookmarks(bookmarks);
+    }
+
+    public void processDeletedBookmarks(final Account account, final Collection<Jid> bookmarks) {
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid()
+                        + ": "
+                        + bookmarks.size()
+                        + " bookmarks have been removed");
+        for (final Jid bookmark : bookmarks) {
+            processDeletedBookmark(account, bookmark);
+        }
+    }
+
+    public void processDeletedBookmark(final Account account, final Jid jid) {
+        final Conversation conversation = service.find(account, jid);
+        if (conversation == null) {
+            return;
+        }
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid() + ": archiving MUC " + jid + " after PEP update");
+        this.service.archiveConversation(conversation, false);
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java 🔗

@@ -0,0 +1,15 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.pubsub.Items;
+
+public class AvatarManager extends AbstractManager {
+
+    public AvatarManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void handleItems(Jid from, final Items items) {}
+}

src/main/java/eu/siacs/conversations/xmpp/manager/AxolotlManager.java 🔗

@@ -0,0 +1,15 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.pubsub.Items;
+
+public class AxolotlManager extends AbstractManager {
+
+    public AxolotlManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void handleItems(Jid from, final Items items) {}
+}

src/main/java/eu/siacs/conversations/xmpp/manager/BookmarkManager.java 🔗

@@ -0,0 +1,95 @@
+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.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.pubsub.Items;
+import java.util.Map;
+
+public class BookmarkManager extends AbstractBookmarkManager {
+
+    public BookmarkManager(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 =
+                                    Bookmark.parseFromItem(
+                                            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) {
+        final var retractions = items.getRetractions();
+        final var itemMap = items.getItemMap(Conference.class);
+        if (!retractions.isEmpty()) {
+            // deleteItems(retractions);
+        }
+        if (!itemMap.isEmpty()) {
+            // updateItems(itemMap);
+        }
+    }
+
+    public ListenableFuture<Void> publishBookmark(final Jid address, final boolean autoJoin) {
+        return publishBookmark(address, autoJoin, null);
+    }
+
+    public ListenableFuture<Void> publishBookmark(
+            final Jid address, final boolean autoJoin, final String nick) {
+        final var itemId = address.toString();
+        final var conference = new Conference();
+        conference.setAutoJoin(autoJoin);
+        if (nick != null) {
+            conference.addExtension(new Nick()).setContent(nick);
+        }
+        return Futures.transform(
+                getManager(PepManager.class)
+                        .publish(conference, itemId, NodeConfiguration.WHITELIST_MAX_ITEMS),
+                result -> null,
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Void> retractBookmark(final Jid address) {
+        final var itemId = address.toString();
+        return Futures.transform(
+                getManager(PepManager.class).retract(itemId, Namespace.BOOKMARKS2),
+                result -> null,
+                MoreExecutors.directExecutor());
+    }
+
+    public void deleteAllItems() {}
+}

src/main/java/eu/siacs/conversations/xmpp/manager/LegacyBookmarkManager.java 🔗

@@ -0,0 +1,15 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.pubsub.Items;
+
+public class LegacyBookmarkManager extends AbstractBookmarkManager {
+
+    public LegacyBookmarkManager(
+            final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+    }
+
+    public void handleItems(final Items items) {}
+}

src/main/java/eu/siacs/conversations/xmpp/manager/NickManager.java 🔗

@@ -0,0 +1,31 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.ListenableFuture;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.NodeConfiguration;
+import im.conversations.android.xmpp.model.nick.Nick;
+import im.conversations.android.xmpp.model.pubsub.Items;
+
+public class NickManager extends AbstractManager {
+
+    public NickManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void handleItems(final Jid from, Items items) {
+        final var item = items.getFirstItem(Nick.class);
+        final var nick = item == null ? null : item.getContent();
+        if (from == null || Strings.isNullOrEmpty(nick)) {
+            return;
+        }
+    }
+
+    public ListenableFuture<Void> publishNick(final String name) {
+        final Nick nick = new Nick();
+        nick.setContent(name);
+        return getManager(PepManager.class).publishSingleton(nick, NodeConfiguration.PRESENCE);
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java 🔗

@@ -0,0 +1,53 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import com.google.common.util.concurrent.ListenableFuture;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.NodeConfiguration;
+import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import java.util.Map;
+
+public class PepManager extends AbstractManager {
+
+    public PepManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public <T extends Extension> ListenableFuture<Map<String, T>> fetchItems(final Class<T> clazz) {
+        return pubSubManager().fetchItems(pepService(), clazz);
+    }
+
+    public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(
+            final String node, final Class<T> clazz) {
+        return pubSubManager().fetchMostRecentItem(pepService(), node, clazz);
+    }
+
+    public ListenableFuture<Void> publish(
+            Extension item, final String itemId, final NodeConfiguration nodeConfiguration) {
+        return pubSubManager().publish(pepService(), item, itemId, nodeConfiguration);
+    }
+
+    public ListenableFuture<Void> publishSingleton(
+            Extension item, final String node, final NodeConfiguration nodeConfiguration) {
+        return pubSubManager().publishSingleton(pepService(), item, node, nodeConfiguration);
+    }
+
+    public ListenableFuture<Void> publishSingleton(
+            final Extension item, final NodeConfiguration nodeConfiguration) {
+        return pubSubManager().publishSingleton(pepService(), item, nodeConfiguration);
+    }
+
+    public ListenableFuture<Iq> retract(final String itemId, final String node) {
+        return pubSubManager().retract(pepService(), itemId, node);
+    }
+
+    private PubSubManager pubSubManager() {
+        return getManager(PubSubManager.class);
+    }
+
+    private Jid pepService() {
+        return connection.getAccount().getJid().asBareJid();
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/PrivateStorageManager.java 🔗

@@ -0,0 +1,55 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Bookmark;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.bookmark.Storage;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.storage.PrivateStorage;
+import java.util.Map;
+
+public class PrivateStorageManager extends AbstractBookmarkManager {
+
+    public PrivateStorageManager(final XmppConnectionService service, XmppConnection connection) {
+        super(service, connection);
+    }
+
+    public void fetchBookmarks() {
+        final var iq = new Iq(Iq.Type.GET);
+        final var privateStorage = iq.addExtension(new PrivateStorage());
+        privateStorage.addExtension(new Storage());
+        final var future = this.connection.sendIqPacket(iq);
+        Futures.addCallback(
+                future,
+                new FutureCallback<Iq>() {
+                    @Override
+                    public void onSuccess(Iq result) {
+                        final var privateStorage = result.getExtension(PrivateStorage.class);
+                        if (privateStorage == null) {
+                            return;
+                        }
+                        final var bookmarkStorage = privateStorage.getExtension(Storage.class);
+                        final Map<Jid, Bookmark> bookmarks =
+                                Bookmark.parseFromStorage(bookmarkStorage, getAccount());
+                        processBookmarksInitial(bookmarks, false);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": could not fetch bookmark from private storage",
+                                t);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java 🔗

@@ -0,0 +1,349 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.util.concurrent.AsyncFunction;
+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.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.ExtensionFactory;
+import im.conversations.android.xmpp.IqErrorException;
+import im.conversations.android.xmpp.NodeConfiguration;
+import im.conversations.android.xmpp.PreconditionNotMetException;
+import im.conversations.android.xmpp.PubSubErrorException;
+import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.data.Data;
+import im.conversations.android.xmpp.model.pubsub.Items;
+import im.conversations.android.xmpp.model.pubsub.PubSub;
+import im.conversations.android.xmpp.model.pubsub.Publish;
+import im.conversations.android.xmpp.model.pubsub.PublishOptions;
+import im.conversations.android.xmpp.model.pubsub.Retract;
+import im.conversations.android.xmpp.model.pubsub.error.PubSubError;
+import im.conversations.android.xmpp.model.pubsub.event.Delete;
+import im.conversations.android.xmpp.model.pubsub.event.Event;
+import im.conversations.android.xmpp.model.pubsub.event.Purge;
+import im.conversations.android.xmpp.model.pubsub.owner.Configure;
+import im.conversations.android.xmpp.model.pubsub.owner.PubSubOwner;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.stanza.Message;
+import java.util.Map;
+
+public class PubSubManager extends AbstractManager {
+
+    private static final String SINGLETON_ITEM_ID = "current";
+
+    public PubSubManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void handleEvent(final Message message) {
+        final var event = message.getExtension(Event.class);
+        final var action = event.getAction();
+        final var from = message.getFrom();
+
+        if (from instanceof Jid.Invalid) {
+            Log.d(
+                    Config.LOGTAG,
+                    getAccount().getJid().asBareJid() + ": ignoring event from invalid jid");
+            return;
+        }
+
+        if (action instanceof Purge purge) {
+            // purge is a deletion of all items in a node
+            handlePurge(message, purge);
+        } else if (action instanceof Items items) {
+            // the items wrapper contains, new and updated items as well as retractions which are
+            // deletions of individual items in a node
+            handleItems(message, items);
+        } else if (action instanceof Delete delete) {
+            // delete is the deletion of the node itself
+            handleDelete(message, delete);
+        }
+    }
+
+    public <T extends Extension> ListenableFuture<Map<String, T>> fetchItems(
+            final Jid address, final Class<T> clazz) {
+        final var id = ExtensionFactory.id(clazz);
+        if (id == null) {
+            return Futures.immediateFailedFuture(
+                    new IllegalArgumentException(
+                            String.format("%s is not a registered extension", clazz.getName())));
+        }
+        return fetchItems(address, id.namespace, clazz);
+    }
+
+    public <T extends Extension> ListenableFuture<Map<String, T>> fetchItems(
+            final Jid address, final String node, final Class<T> clazz) {
+        final Iq request = new Iq(Iq.Type.GET);
+        request.setTo(address);
+        final var pubSub = request.addExtension(new PubSub());
+        final var itemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
+        itemsWrapper.setNode(node);
+        return Futures.transform(
+                connection.sendIqPacket(request),
+                response -> {
+                    final var pubSubResponse = response.getExtension(PubSub.class);
+                    if (pubSubResponse == null) {
+                        throw new IllegalStateException();
+                    }
+                    final var items = pubSubResponse.getItems();
+                    if (items == null) {
+                        throw new IllegalStateException();
+                    }
+                    return items.getItemMap(clazz);
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public <T extends Extension> ListenableFuture<T> fetchItem(
+            final Jid address, final String itemId, final Class<T> clazz) {
+        final var id = ExtensionFactory.id(clazz);
+        if (id == null) {
+            return Futures.immediateFailedFuture(
+                    new IllegalArgumentException(
+                            String.format("%s is not a registered extension", clazz.getName())));
+        }
+        return fetchItem(address, id.namespace, itemId, clazz);
+    }
+
+    public <T extends Extension> ListenableFuture<T> fetchItem(
+            final Jid address, final String node, final String itemId, final Class<T> clazz) {
+        final Iq request = new Iq(Iq.Type.GET);
+        request.setTo(address);
+        final var pubSub = request.addExtension(new PubSub());
+        final var itemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
+        itemsWrapper.setNode(node);
+        final var item = itemsWrapper.addExtension(new PubSub.Item());
+        item.setId(itemId);
+        return Futures.transform(
+                connection.sendIqPacket(request),
+                response -> {
+                    final var pubSubResponse = response.getExtension(PubSub.class);
+                    if (pubSubResponse == null) {
+                        throw new IllegalStateException();
+                    }
+                    final var items = pubSubResponse.getItems();
+                    if (items == null) {
+                        throw new IllegalStateException();
+                    }
+                    return items.getItemOrThrow(itemId, clazz);
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(
+            final Jid address, final String node, final Class<T> clazz) {
+        final Iq request = new Iq(Iq.Type.GET);
+        request.setTo(address);
+        final var pubSub = request.addExtension(new PubSub());
+        final var itemsWrapper = pubSub.addExtension(new PubSub.ItemsWrapper());
+        itemsWrapper.setNode(node);
+        itemsWrapper.setMaxItems(1);
+        return Futures.transform(
+                connection.sendIqPacket(request),
+                response -> {
+                    final var pubSubResponse = response.getExtension(PubSub.class);
+                    if (pubSubResponse == null) {
+                        throw new IllegalStateException();
+                    }
+                    final var items = pubSubResponse.getItems();
+                    if (items == null) {
+                        throw new IllegalStateException();
+                    }
+                    return items.getOnlyItem(clazz);
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void handleItems(final Message message, final Items items) {
+        final var from = message.getFrom();
+        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);
+            return;
+        }
+        if (connection.fromAccount(message) && Namespace.BOOKMARKS.equals(node)) {
+            getManager(LegacyBookmarkManager.class).handleItems(items);
+            return;
+        }
+        if (isFromBare && Namespace.AVATAR_METADATA.equals(node)) {
+            getManager(AvatarManager.class).handleItems(from, items);
+            return;
+        }
+        if (isFromBare && Namespace.NICK.equals(node)) {
+            getManager(NickManager.class).handleItems(from, items);
+            return;
+        }
+        if (isFromBare && Namespace.AXOLOTL_DEVICE_LIST.equals(node)) {
+            getManager(AxolotlManager.class).handleItems(from, items);
+        }
+    }
+
+    private void handlePurge(final Message message, final Purge purge) {
+        final var from = message.getFrom();
+        final var node = purge.getNode();
+        if (connection.fromAccount(message) && Namespace.BOOKMARKS2.equals(node)) {
+            getManager(BookmarkManager.class).deleteAllItems();
+        }
+    }
+
+    private void handleDelete(final Message message, final Delete delete) {}
+
+    public ListenableFuture<Void> publishSingleton(
+            Jid address, Extension item, final NodeConfiguration nodeConfiguration) {
+        final var id = ExtensionFactory.id(item.getClass());
+        return publish(address, item, SINGLETON_ITEM_ID, id.namespace, nodeConfiguration);
+    }
+
+    public ListenableFuture<Void> publishSingleton(
+            Jid address,
+            Extension item,
+            final String node,
+            final NodeConfiguration nodeConfiguration) {
+        return publish(address, item, SINGLETON_ITEM_ID, node, nodeConfiguration);
+    }
+
+    public ListenableFuture<Void> publish(
+            Jid address,
+            Extension item,
+            final String itemId,
+            final NodeConfiguration nodeConfiguration) {
+        final var id = ExtensionFactory.id(item.getClass());
+        return publish(address, item, itemId, id.namespace, nodeConfiguration);
+    }
+
+    public ListenableFuture<Void> publish(
+            final Jid address,
+            final Extension itemPayload,
+            final String itemId,
+            final String node,
+            final NodeConfiguration nodeConfiguration) {
+        final var future = publishNoRetry(address, itemPayload, itemId, node, nodeConfiguration);
+        return Futures.catchingAsync(
+                future,
+                PreconditionNotMetException.class,
+                ex -> {
+                    Log.d(
+                            Config.LOGTAG,
+                            "Node " + node + " on " + address + " requires reconfiguration");
+                    final var reconfigurationFuture =
+                            reconfigureNode(address, node, nodeConfiguration);
+                    return Futures.transformAsync(
+                            reconfigurationFuture,
+                            ignored ->
+                                    publishNoRetry(
+                                            address, itemPayload, itemId, node, nodeConfiguration),
+                            MoreExecutors.directExecutor());
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Void> publishNoRetry(
+            final Jid address,
+            final Extension itemPayload,
+            final String itemId,
+            final String node,
+            final NodeConfiguration nodeConfiguration) {
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var pubSub = iq.addExtension(new PubSub());
+        final var publish = pubSub.addExtension(new Publish());
+        publish.setNode(node);
+        final var item = publish.addExtension(new PubSub.Item());
+        item.setId(itemId);
+        item.addExtension(itemPayload);
+        pubSub.addExtension(PublishOptions.of(nodeConfiguration));
+        final ListenableFuture<Void> iqFuture =
+                Futures.transform(
+                        connection.sendIqPacket(iq),
+                        result -> null,
+                        MoreExecutors.directExecutor());
+        return Futures.catchingAsync(
+                iqFuture,
+                IqErrorException.class,
+                new PubSubExceptionTransformer<>(),
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Void> reconfigureNode(
+            final Jid address, final String node, final NodeConfiguration nodeConfiguration) {
+        final Iq iq = new Iq(Iq.Type.GET);
+        iq.setTo(address);
+        final var pubSub = iq.addExtension(new PubSubOwner());
+        final var configure = pubSub.addExtension(new Configure());
+        configure.setNode(node);
+        return Futures.transformAsync(
+                connection.sendIqPacket(iq),
+                result -> {
+                    final var pubSubOwnerResult = result.getExtension(PubSubOwner.class);
+                    final Configure configureResult =
+                            pubSubOwnerResult == null
+                                    ? null
+                                    : pubSubOwnerResult.getExtension(Configure.class);
+                    if (configureResult == null) {
+                        throw new IllegalStateException(
+                                "No configuration found in configuration request result");
+                    }
+                    final var data = configureResult.getData();
+                    return setNodeConfiguration(address, node, data.submit(nodeConfiguration));
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Void> setNodeConfiguration(
+            final Jid address, final String node, final Data data) {
+        final Iq iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var pubSub = iq.addExtension(new PubSubOwner());
+        final var configure = pubSub.addExtension(new Configure());
+        configure.setNode(node);
+        configure.addExtension(data);
+        return Futures.transform(
+                connection.sendIqPacket(iq),
+                result -> {
+                    Log.d(Config.LOGTAG, "Modified node configuration " + node + " on " + address);
+                    return null;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Iq> retract(final Jid address, final String itemId, final String node) {
+        final var iq = new Iq(Iq.Type.SET);
+        iq.setTo(address);
+        final var pubSub = iq.addExtension(new PubSub());
+        final var retract = pubSub.addExtension(new Retract());
+        retract.setNode(node);
+        retract.setNotify(true);
+        final var item = retract.addExtension(new PubSub.Item());
+        item.setId(itemId);
+        return connection.sendIqPacket(iq);
+    }
+
+    private static class PubSubExceptionTransformer<V>
+            implements AsyncFunction<IqErrorException, V> {
+
+        @Override
+        @NonNull
+        public ListenableFuture<V> apply(@NonNull IqErrorException ex) {
+            final var error = ex.getError();
+            if (error == null) {
+                return Futures.immediateFailedFuture(ex);
+            }
+            final PubSubError pubSubError = error.getExtension(PubSubError.class);
+            if (pubSubError instanceof PubSubError.PreconditionNotMet) {
+                return Futures.immediateFailedFuture(
+                        new PreconditionNotMetException(ex.getResponse()));
+            } else if (pubSubError != null) {
+                return Futures.immediateFailedFuture(new PubSubErrorException(ex.getResponse()));
+            } else {
+                return Futures.immediateFailedFuture(ex);
+            }
+        }
+    }
+}

src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java 🔗

@@ -7,6 +7,8 @@ import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.generator.IqGenerator;
 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.PrivateStorageManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import im.conversations.android.xmpp.model.stanza.Iq;
 
@@ -21,6 +23,7 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
 
     @Override
     public void run() {
+        Log.d(Config.LOGTAG, "begin onBind()");
         final var account = connection.getAccount();
         final var features = connection.getFeatures();
         service.cancelAvatarFetches(account);
@@ -63,9 +66,10 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
         getManager(RosterManager.class).request();
 
         if (features.bookmarks2()) {
-            service.fetchBookmarks2(account);
+            connection.getManager(BookmarkManager.class).fetch();
+            // log that we use bookmarks 1 and wait for +notify
         } else if (!features.bookmarksConversion()) {
-            service.fetchBookmarks(account);
+            connection.getManager(PrivateStorageManager.class).fetchBookmarks();
         }
 
         if (features.mds()) {
@@ -101,5 +105,6 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
         connection.getManager(RosterManager.class).syncDirtyContacts();
 
         service.getUnifiedPushBroker().renewUnifiedPushEndpointsOnBind(account);
+        Log.d(Config.LOGTAG, "end onBind()");
     }
 }