rudimentary RosterManager and BlockingManager

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/entities/Account.java                                 |  42 
src/main/java/eu/siacs/conversations/entities/MucOptions.java                              |   4 
src/main/java/eu/siacs/conversations/entities/Roster.java                                  |  95 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java                            |  29 
src/main/java/eu/siacs/conversations/parser/IqParser.java                                  | 255 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                             |   7 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                            |  15 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java                      |  24 
src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java                       |   4 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java                   |  59 
src/main/java/eu/siacs/conversations/utils/ReplacingTaskManager.java                       |  57 
src/main/java/eu/siacs/conversations/xml/Namespace.java                                    |   1 
src/main/java/eu/siacs/conversations/xmpp/Managers.java                                    |  12 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                              |  46 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java              |   1 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java |   1 
src/main/java/eu/siacs/conversations/xmpp/manager/AbstractManager.java                     |   2 
src/main/java/eu/siacs/conversations/xmpp/manager/BlockingManager.java                     | 144 
src/main/java/eu/siacs/conversations/xmpp/manager/DiscoManager.java                        |   2 
src/main/java/eu/siacs/conversations/xmpp/manager/EntityTimeManager.java                   |  43 
src/main/java/eu/siacs/conversations/xmpp/manager/PingManager.java                         |   4 
src/main/java/eu/siacs/conversations/xmpp/manager/RosterManager.java                       | 276 
src/main/java/eu/siacs/conversations/xmpp/manager/UnifiedPushManager.java                  |  34 
src/main/java/im/conversations/android/xmpp/model/blocking/Block.java                      |   5 
src/main/java/im/conversations/android/xmpp/model/blocking/Blocklist.java                  |   5 
src/main/java/im/conversations/android/xmpp/model/blocking/Unblock.java                    |   5 
src/main/java/im/conversations/android/xmpp/model/ibb/Close.java                           |  11 
src/main/java/im/conversations/android/xmpp/model/ibb/Data.java                            |  12 
src/main/java/im/conversations/android/xmpp/model/ibb/InBandByteStream.java                |  14 
src/main/java/im/conversations/android/xmpp/model/ibb/Open.java                            |  11 
src/main/java/im/conversations/android/xmpp/model/ibb/package-info.java                    |   5 
src/main/java/im/conversations/android/xmpp/model/roster/Query.java                        |   5 
src/main/java/im/conversations/android/xmpp/model/time/Time.java                           |  20 
src/main/java/im/conversations/android/xmpp/model/time/TimeZoneOffset.java                 |  12 
src/main/java/im/conversations/android/xmpp/model/time/UniversalTime.java                  |  12 
src/main/java/im/conversations/android/xmpp/model/time/package-info.java                   |   5 
src/main/java/im/conversations/android/xmpp/model/up/Push.java                             |  13 
src/main/java/im/conversations/android/xmpp/model/up/package-info.java                     |   5 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java                   |   5 
src/quicksy/java/eu/siacs/conversations/services/QuickConversationsService.java            |  21 
src/test/java/im/conversations/android/xmpp/EntityCapabilitiesTest.java                    | 100 
41 files changed, 922 insertions(+), 501 deletions(-)

Detailed changes

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

@@ -27,15 +27,17 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
+import eu.siacs.conversations.xmpp.manager.BlockingManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.RosterManager;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.CopyOnWriteArraySet;
 import org.json.JSONException;
 import org.json.JSONObject;
 
@@ -77,10 +79,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     private static final String KEY_PINNED_MECHANISM = "pinned_mechanism";
     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;
-    private final Roster roster = new Roster(this);
-    private final Collection<Jid> blocklist = new CopyOnWriteArraySet<>();
     public final Set<Conversation> pendingConferenceJoins = new HashSet<>();
     public final Set<Conversation> pendingConferenceLeaves = new HashSet<>();
     public final Set<Conversation> inProgressConferenceJoins = new HashSet<>();
@@ -550,11 +549,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public void initAccountServices(final XmppConnectionService context) {
+        this.xmppConnection = new XmppConnection(this, context);
         this.axolotlService = new AxolotlService(this, context);
         this.pgpDecryptionService = new PgpDecryptionService(context);
-        if (xmppConnection != null) {
-            xmppConnection.addOnAdvancedStreamFeaturesAvailableListener(axolotlService);
-        }
+        this.xmppConnection.addOnAdvancedStreamFeaturesAvailableListener(axolotlService);
     }
 
     public PgpDecryptionService getPgpDecryptionService() {
@@ -565,16 +563,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         return this.xmppConnection;
     }
 
-    public void setXmppConnection(final XmppConnection connection) {
-        this.xmppConnection = connection;
-    }
-
     public String getRosterVersion() {
-        if (this.rosterVersion == null) {
-            return "";
-        } else {
-            return this.rosterVersion;
-        }
+        return Strings.emptyToNull(this.rosterVersion);
     }
 
     public void setRosterVersion(final String version) {
@@ -648,7 +638,11 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public Roster getRoster() {
-        return this.roster;
+        if (xmppConnection != null) {
+            return xmppConnection.getManager(RosterManager.class);
+        }
+        // TODO either return stub or always put XmppConnection into Account
+        return null;
     }
 
     public Collection<Bookmark> getBookmarks() {
@@ -767,20 +761,22 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
 
     public boolean isBlocked(final ListItem contact) {
         final Jid jid = contact.getJid();
+        final var blocklist = getBlocklist();
         return jid != null
                 && (blocklist.contains(jid.asBareJid()) || blocklist.contains(jid.getDomain()));
     }
 
     public boolean isBlocked(final Jid jid) {
+        final var blocklist = getBlocklist();
         return jid != null && blocklist.contains(jid.asBareJid());
     }
 
-    public Collection<Jid> getBlocklist() {
-        return this.blocklist;
-    }
-
-    public void clearBlocklist() {
-        getBlocklist().clear();
+    public Set<Jid> getBlocklist() {
+        final var connection = this.xmppConnection;
+        if (connection == null) {
+            return Collections.emptySet();
+        }
+        return connection.getManager(BlockingManager.class).getBlocklist();
     }
 
     public boolean isOnlineAndConnected() {

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

@@ -883,7 +883,9 @@ public class MucOptions {
 
         public Contact getContact() {
             if (fullJid != null) {
-                return getAccount().getRoster().getContactFromContactList(realJid);
+                return realJid == null
+                        ? null
+                        : getAccount().getRoster().getContactFromContactList(realJid);
             } else if (realJid != null) {
                 return getAccount().getRoster().getContact(realJid);
             } else {

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

@@ -1,98 +1,17 @@
 package eu.siacs.conversations.entities;
 
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-
+import androidx.annotation.NonNull;
 import eu.siacs.conversations.android.AbstractPhoneContact;
 import eu.siacs.conversations.xmpp.Jid;
+import java.util.List;
 
+public interface Roster {
 
-public class Roster {
-	private final Account account;
-	private final HashMap<Jid, Contact> contacts = new HashMap<>();
-	private String version = null;
-
-	public Roster(Account account) {
-		this.account = account;
-	}
-
-	public Contact getContactFromContactList(Jid jid) {
-		if (jid == null) {
-			return null;
-		}
-		synchronized (this.contacts) {
-			Contact contact = contacts.get(jid.asBareJid());
-			if (contact != null && contact.showInContactList()) {
-				return contact;
-			} else {
-				return null;
-			}
-		}
-	}
-
-	public Contact getContact(final Jid jid) {
-		synchronized (this.contacts) {
-			if (!contacts.containsKey(jid.asBareJid())) {
-				Contact contact = new Contact(jid.asBareJid());
-				contact.setAccount(account);
-				contacts.put(contact.getJid().asBareJid(), contact);
-				return contact;
-			}
-			return contacts.get(jid.asBareJid());
-		}
-	}
-
-	public void clearPresences() {
-		for (Contact contact : getContacts()) {
-			contact.clearPresences();
-		}
-	}
-
-	public void markAllAsNotInRoster() {
-		for (Contact contact : getContacts()) {
-			contact.resetOption(Contact.Options.IN_ROSTER);
-		}
-	}
-
-	public List<Contact> getWithSystemAccounts(Class<?extends AbstractPhoneContact> clazz) {
-		int option = Contact.getOption(clazz);
-		List<Contact> with = getContacts();
-		for(Iterator<Contact> iterator = with.iterator(); iterator.hasNext();) {
-			Contact contact = iterator.next();
-			if (!contact.getOption(option)) {
-				iterator.remove();
-			}
-		}
-		return with;
-	}
-
-	public List<Contact> getContacts() {
-		synchronized (this.contacts) {
-			return new ArrayList<>(this.contacts.values());
-		}
-	}
-
-	public void initContact(final Contact contact) {
-		if (contact == null) {
-			return;
-		}
-		contact.setAccount(account);
-		synchronized (this.contacts) {
-			contacts.put(contact.getJid().asBareJid(), contact);
-		}
-	}
+    List<Contact> getContacts();
 
-	public void setVersion(String version) {
-		this.version = version;
-	}
+    List<Contact> getWithSystemAccounts(Class<? extends AbstractPhoneContact> clazz);
 
-	public String getVersion() {
-		return this.version;
-	}
+    Contact getContact(@NonNull final Jid jid);
 
-	public Account getAccount() {
-		return this.account;
-	}
+    Contact getContactFromContactList(@NonNull final Jid jid);
 }

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -23,9 +23,7 @@ import java.security.cert.CertificateEncodingException;
 import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Locale;
 import java.util.Set;
-import java.util.TimeZone;
 import java.util.UUID;
 import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.ecc.ECPublicKey;
@@ -38,26 +36,6 @@ public class IqGenerator extends AbstractGenerator {
         super(service);
     }
 
-    public Iq entityTimeResponse(final Iq request) {
-        final Iq packet = request.generateResponse(Iq.Type.RESULT);
-        Element time = packet.addChild("time", "urn:xmpp:time");
-        final long now = System.currentTimeMillis();
-        time.addChild("utc").setContent(getTimestamp(now));
-        TimeZone ourTimezone = TimeZone.getDefault();
-        long offsetSeconds = ourTimezone.getOffset(now) / 1000;
-        long offsetMinutes = Math.abs((offsetSeconds % 3600) / 60);
-        long offsetHours = offsetSeconds / 3600;
-        String hours;
-        if (offsetHours < 0) {
-            hours = String.format(Locale.US, "%03d", offsetHours);
-        } else {
-            hours = String.format(Locale.US, "%02d", offsetHours);
-        }
-        String minutes = String.format(Locale.US, "%02d", offsetMinutes);
-        time.addChild("tzo").setContent(hours + ":" + minutes);
-        return packet;
-    }
-
     public static Iq purgeOfflineMessages() {
         final Iq packet = new Iq(Iq.Type.SET);
         packet.addChild("offline", Namespace.FLEXIBLE_OFFLINE_MESSAGE_RETRIEVAL).addChild("purge");
@@ -338,13 +316,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq generateGetBlockList() {
-        final Iq iq = new Iq(Iq.Type.GET);
-        iq.addChild("blocklist", Namespace.BLOCKING);
-
-        return iq;
-    }
-
     public Iq generateSetBlockRequest(
             final Jid jid, final boolean reportSpam, final String serverMsgId) {
         final Iq iq = new Iq(Iq.Type.SET);

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

@@ -7,24 +7,33 @@ import com.google.common.base.CharMatcher;
 import com.google.common.io.BaseEncoding;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.Jid;
-import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.manager.BlockingManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.EntityTimeManager;
+import eu.siacs.conversations.xmpp.manager.PingManager;
+import eu.siacs.conversations.xmpp.manager.RosterManager;
+import eu.siacs.conversations.xmpp.manager.UnifiedPushManager;
+import im.conversations.android.xmpp.model.blocking.Block;
+import im.conversations.android.xmpp.model.blocking.Unblock;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
+import im.conversations.android.xmpp.model.ibb.InBandByteStream;
+import im.conversations.android.xmpp.model.ping.Ping;
+import im.conversations.android.xmpp.model.roster.Query;
 import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.time.Time;
+import im.conversations.android.xmpp.model.up.Push;
 import im.conversations.android.xmpp.model.version.Version;
 import java.io.ByteArrayInputStream;
 import java.security.cert.CertificateException;
 import java.security.cert.CertificateFactory;
 import java.security.cert.X509Certificate;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -43,76 +52,6 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
         super(service, connection);
     }
 
-    public static List<Jid> items(final Iq packet) {
-        ArrayList<Jid> items = new ArrayList<>();
-        final Element query = packet.findChild("query", Namespace.DISCO_ITEMS);
-        if (query == null) {
-            return items;
-        }
-        for (Element child : query.getChildren()) {
-            if ("item".equals(child.getName())) {
-                Jid jid = child.getAttributeAsJid("jid");
-                if (jid != null) {
-                    items.add(jid);
-                }
-            }
-        }
-        return items;
-    }
-
-    private void rosterItems(final Account account, final Element query) {
-        final String version = query.getAttribute("ver");
-        if (version != null) {
-            account.getRoster().setVersion(version);
-        }
-        for (final Element item : query.getChildren()) {
-            if (item.getName().equals("item")) {
-                final Jid jid = Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("jid"));
-                if (jid == null) {
-                    continue;
-                }
-                final String name = item.getAttribute("name");
-                final String subscription = item.getAttribute("subscription");
-                final Contact contact = account.getRoster().getContact(jid);
-                boolean bothPre =
-                        contact.getOption(Contact.Options.TO)
-                                && contact.getOption(Contact.Options.FROM);
-                if (!contact.getOption(Contact.Options.DIRTY_PUSH)) {
-                    contact.setServerName(name);
-                    contact.parseGroupsFromElement(item);
-                }
-                if ("remove".equals(subscription)) {
-                    contact.resetOption(Contact.Options.IN_ROSTER);
-                    contact.resetOption(Contact.Options.DIRTY_DELETE);
-                    contact.resetOption(Contact.Options.PREEMPTIVE_GRANT);
-                } else {
-                    contact.setOption(Contact.Options.IN_ROSTER);
-                    contact.resetOption(Contact.Options.DIRTY_PUSH);
-                    contact.parseSubscriptionFromElement(item);
-                }
-                boolean both =
-                        contact.getOption(Contact.Options.TO)
-                                && contact.getOption(Contact.Options.FROM);
-                if ((both != bothPre) && both) {
-                    Log.d(
-                            Config.LOGTAG,
-                            account.getJid().asBareJid()
-                                    + ": gained mutual presence subscription with "
-                                    + contact.getJid());
-                    AxolotlService axolotlService = account.getAxolotlService();
-                    if (axolotlService != null) {
-                        axolotlService.clearErrorsInFetchStatusMap(contact.getJid());
-                    }
-                }
-                mXmppConnectionService.getAvatarService().clear(contact);
-            }
-        }
-        mXmppConnectionService.updateConversationUi();
-        mXmppConnectionService.updateRosterUi();
-        mXmppConnectionService.getShortcutService().refresh();
-        mXmppConnectionService.syncRoster(account);
-    }
-
     public static String avatarData(final Iq packet) {
         final Element pubsub = packet.findChild("pubsub", Namespace.PUBSUB);
         if (pubsub == null) {
@@ -381,139 +320,47 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
 
     @Override
     public void accept(final Iq packet) {
-        final var account = getAccount();
-        final boolean isGet = packet.getType() == Iq.Type.GET;
-        if (packet.getType() == Iq.Type.ERROR || packet.getType() == Iq.Type.TIMEOUT) {
-            return;
+        final var type = packet.getType();
+        switch (type) {
+            case SET -> acceptPush(packet);
+            case GET -> acceptRequest(packet);
+            default ->
+                    throw new AssertionError(
+                            "IQ results and errors should are handled in callbacks");
         }
-        if (packet.hasChild("query", Namespace.ROSTER) && packet.fromServer(account)) {
-            final Element query = packet.findChild("query");
-            // If this is in response to a query for the whole roster:
-            if (packet.getType() == Iq.Type.RESULT) {
-                account.getRoster().markAllAsNotInRoster();
-            }
-            this.rosterItems(account, query);
-        } else if ((packet.hasChild("block", Namespace.BLOCKING)
-                        || packet.hasChild("blocklist", Namespace.BLOCKING))
-                && packet.fromServer(account)) {
-            // Block list or block push.
-            Log.d(Config.LOGTAG, "Received blocklist update from server");
-            final Element blocklist = packet.findChild("blocklist", Namespace.BLOCKING);
-            final Element block = packet.findChild("block", Namespace.BLOCKING);
-            final Collection<Element> items =
-                    blocklist != null
-                            ? blocklist.getChildren()
-                            : (block != null ? block.getChildren() : null);
-            // If this is a response to a blocklist query, clear the block list and replace with the
-            // new one.
-            // Otherwise, just update the existing blocklist.
-            if (packet.getType() == Iq.Type.RESULT) {
-                account.clearBlocklist();
-                connection.getFeatures().setBlockListRequested(true);
-            }
-            if (items != null) {
-                final Collection<Jid> jids = new ArrayList<>(items.size());
-                // Create a collection of Jids from the packet
-                for (final Element item : items) {
-                    if (item.getName().equals("item")) {
-                        final Jid jid =
-                                Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("jid"));
-                        if (jid != null) {
-                            jids.add(jid);
-                        }
-                    }
-                }
-                account.getBlocklist().addAll(jids);
-                if (packet.getType() == Iq.Type.SET) {
-                    boolean removed = false;
-                    for (Jid jid : jids) {
-                        removed |= mXmppConnectionService.removeBlockedConversations(account, jid);
-                    }
-                    if (removed) {
-                        mXmppConnectionService.updateConversationUi();
-                    }
-                }
-            }
-            // Update the UI
-            mXmppConnectionService.updateBlocklistUi(OnUpdateBlocklist.Status.BLOCKED);
-            if (packet.getType() == Iq.Type.SET) {
-                final Iq response = packet.generateResponse(Iq.Type.RESULT);
-                mXmppConnectionService.sendIqPacket(account, response, null);
-            }
-        } else if (packet.hasChild("unblock", Namespace.BLOCKING)
-                && packet.fromServer(account)
-                && packet.getType() == Iq.Type.SET) {
-            Log.d(Config.LOGTAG, "Received unblock update from server");
-            final Collection<Element> items =
-                    packet.findChild("unblock", Namespace.BLOCKING).getChildren();
-            if (items.isEmpty()) {
-                // No children to unblock == unblock all
-                account.getBlocklist().clear();
-            } else {
-                final Collection<Jid> jids = new ArrayList<>(items.size());
-                for (final Element item : items) {
-                    if (item.getName().equals("item")) {
-                        final Jid jid =
-                                Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("jid"));
-                        if (jid != null) {
-                            jids.add(jid);
-                        }
-                    }
-                }
-                account.getBlocklist().removeAll(jids);
-            }
-            mXmppConnectionService.updateBlocklistUi(OnUpdateBlocklist.Status.UNBLOCKED);
-            final Iq response = packet.generateResponse(Iq.Type.RESULT);
-            mXmppConnectionService.sendIqPacket(account, response, null);
-        } else if (packet.hasChild("open", "http://jabber.org/protocol/ibb")
-                || packet.hasChild("data", "http://jabber.org/protocol/ibb")
-                || packet.hasChild("close", "http://jabber.org/protocol/ibb")) {
-            mXmppConnectionService.getJingleConnectionManager().deliverIbbPacket(account, packet);
-        } else if (packet.hasExtension(InfoQuery.class) && isGet) {
+    }
+
+    private void acceptPush(final Iq packet) {
+        if (packet.hasExtension(Query.class)) {
+            this.getManager(RosterManager.class).push(packet);
+        } else if (packet.hasExtension(Block.class)) {
+            this.getManager(BlockingManager.class).pushBlock(packet);
+        } else if (packet.hasExtension(Unblock.class)) {
+            this.getManager(BlockingManager.class).pushUnblock(packet);
+        } else if (packet.hasExtension(InBandByteStream.class)) {
+            mXmppConnectionService
+                    .getJingleConnectionManager()
+                    .deliverIbbPacket(getAccount(), packet);
+        } else if (packet.hasExtension(Push.class)) {
+            this.getManager(UnifiedPushManager.class).push(packet);
+        } else {
+            this.connection.sendErrorFor(
+                    packet, Error.Type.CANCEL, new Condition.FeatureNotImplemented());
+        }
+    }
+
+    private void acceptRequest(final Iq packet) {
+        if (packet.hasExtension(InfoQuery.class)) {
             this.getManager(DiscoManager.class).handleInfoQuery(packet);
-        } else if (packet.hasExtension(Version.class) && isGet) {
+        } else if (packet.hasExtension(Version.class)) {
             this.getManager(DiscoManager.class).handleVersionRequest(packet);
-        } else if (packet.hasChild("ping", "urn:xmpp:ping") && isGet) {
-            final Iq response = packet.generateResponse(Iq.Type.RESULT);
-            mXmppConnectionService.sendIqPacket(account, response, null);
-        } else if (packet.hasChild("time", "urn:xmpp:time") && isGet) {
-            final Iq response;
-            if (mXmppConnectionService.useTorToConnect() || account.isOnion()) {
-                response = packet.generateResponse(Iq.Type.ERROR);
-                final Element error = response.addChild("error");
-                error.setAttribute("type", "cancel");
-                error.addChild("not-allowed", "urn:ietf:params:xml:ns:xmpp-stanzas");
-            } else {
-                response = mXmppConnectionService.getIqGenerator().entityTimeResponse(packet);
-            }
-            mXmppConnectionService.sendIqPacket(account, response, null);
-        } else if (packet.hasChild("push", Namespace.UNIFIED_PUSH)
-                && packet.getType() == Iq.Type.SET) {
-            final Jid transport = packet.getFrom();
-            final Element push = packet.findChild("push", Namespace.UNIFIED_PUSH);
-            final boolean success =
-                    push != null
-                            && mXmppConnectionService.processUnifiedPushMessage(
-                                    account, transport, push);
-            final Iq response;
-            if (success) {
-                response = packet.generateResponse(Iq.Type.RESULT);
-            } else {
-                response = packet.generateResponse(Iq.Type.ERROR);
-                final Element error = response.addChild("error");
-                error.setAttribute("type", "cancel");
-                error.setAttribute("code", "404");
-                error.addChild("item-not-found", "urn:ietf:params:xml:ns:xmpp-stanzas");
-            }
-            mXmppConnectionService.sendIqPacket(account, response, null);
+        } else if (packet.hasExtension(Time.class)) {
+            this.getManager(EntityTimeManager.class).request(packet);
+        } else if (packet.hasExtension(Ping.class)) {
+            this.getManager(PingManager.class).pong(packet);
         } else {
-            if (packet.getType() == Iq.Type.GET || packet.getType() == Iq.Type.SET) {
-                final Iq response = packet.generateResponse(Iq.Type.ERROR);
-                final Element error = response.addChild("error");
-                error.setAttribute("type", "cancel");
-                error.addChild("feature-not-implemented", "urn:ietf:params:xml:ns:xmpp-stanzas");
-                connection.sendIqPacket(response, null);
-            }
+            this.connection.sendErrorFor(
+                    packet, Error.Type.CANCEL, new Condition.FeatureNotImplemented());
         }
     }
 }

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.RosterManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.avatar.Metadata;
@@ -276,7 +277,7 @@ public class MessageParser extends AbstractParser
                     } else {
                         final Contact contact = account.getRoster().getContact(from);
                         if (contact.setAvatar(avatar)) {
-                            mXmppConnectionService.syncRoster(account);
+                            connection.getManager(RosterManager.class).writeToDatabaseAsync();
                             mXmppConnectionService.getAvatarService().clear(contact);
                             mXmppConnectionService.updateConversationUi();
                             mXmppConnectionService.updateRosterUi();
@@ -410,7 +411,7 @@ public class MessageParser extends AbstractParser
         } else {
             Contact contact = account.getRoster().getContact(user);
             if (contact.setPresenceName(nick)) {
-                mXmppConnectionService.syncRoster(account);
+                connection.getManager(RosterManager.class).writeToDatabaseAsync();
                 mXmppConnectionService.getAvatarService().clear(contact);
             }
         }
@@ -1435,7 +1436,7 @@ public class MessageParser extends AbstractParser
             }
             final Contact contact = account.getRoster().getContact(from);
             if (contact.setPresenceName(nick)) {
-                mXmppConnectionService.syncRoster(account);
+                connection.getManager(RosterManager.class).writeToDatabaseAsync();
                 mXmppConnectionService.getAvatarService().clear(contact);
             }
         }

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

@@ -24,6 +24,7 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.RosterManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
@@ -172,8 +173,9 @@ public class PresenceParser extends AbstractParser
                                                     .getRoster()
                                                     .getContact(user.getRealJid());
                                     if (c.setAvatar(avatar)) {
-                                        mXmppConnectionService.syncRoster(
-                                                conversation.getAccount());
+                                        connection
+                                                .getManager(RosterManager.class)
+                                                .writeToDatabaseAsync();
                                         mXmppConnectionService.getAvatarService().clear(c);
                                     }
                                     mXmppConnectionService.updateRosterUi();
@@ -351,7 +353,7 @@ public class PresenceParser extends AbstractParser
                         mXmppConnectionService.updateAccountUi();
                     } else {
                         if (contact.setAvatar(avatar)) {
-                            mXmppConnectionService.syncRoster(account);
+                            connection.getManager(RosterManager.class).writeToDatabaseAsync();
                             mXmppConnectionService.getAvatarService().clear(contact);
                             mXmppConnectionService.updateConversationUi();
                             mXmppConnectionService.updateRosterUi();
@@ -371,8 +373,7 @@ public class PresenceParser extends AbstractParser
             contact.updatePresence(resource, packet);
 
             final var nodeHash = packet.getCapabilities();
-            final var connection = account.getXmppConnection();
-            if (nodeHash != null && connection != null) {
+            if (nodeHash != null) {
                 final var discoFuture =
                         this.getManager(DiscoManager.class)
                                 .infoOrCache(Entity.presence(from), nodeHash.node, nodeHash.hash);
@@ -410,7 +411,7 @@ public class PresenceParser extends AbstractParser
                                     + contact.getJid()
                                     + " "
                                     + OpenPgpUtils.convertKeyIdToHex(keyId));
-                    mXmppConnectionService.syncRoster(account);
+                    this.connection.getManager(RosterManager.class).writeToDatabaseAsync();
                 }
             }
             boolean online = sizeBefore < contact.getPresences().size();
@@ -440,7 +441,7 @@ public class PresenceParser extends AbstractParser
                 return;
             }
             if (contact.setPresenceName(packet.findChildContent("nick", Namespace.NICK))) {
-                mXmppConnectionService.syncRoster(account);
+                this.getManager(RosterManager.class).writeToDatabaseAsync();
                 mXmppConnectionService.getAvatarService().clear(contact);
             }
             if (contact.getOption(Contact.Options.PREEMPTIVE_GRANT)) {

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -11,6 +11,7 @@ import android.os.SystemClock;
 import android.util.Base64;
 import android.util.Log;
 import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableList;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
@@ -20,7 +21,6 @@ import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.PresenceTemplate;
-import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.ShortcutService;
 import eu.siacs.conversations.utils.CryptoHelper;
@@ -1809,23 +1809,29 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return rows == 1;
     }
 
-    public void readRoster(Roster roster) {
+    public List<Contact> readRoster(final Account account) {
+        final var builder = new ImmutableList.Builder<Contact>();
         final SQLiteDatabase db = this.getReadableDatabase();
-        final String[] args = {roster.getAccount().getUuid()};
+        final String[] args = {account.getUuid()};
         try (final Cursor cursor =
                 db.query(Contact.TABLENAME, null, Contact.ACCOUNT + "=?", args, null, null, null)) {
             while (cursor.moveToNext()) {
-                roster.initContact(Contact.fromCursor(cursor));
+                final var contact = Contact.fromCursor(cursor);
+                if (contact != null) {
+                    contact.setAccount(account);
+                    builder.add(contact);
+                }
             }
         }
+        return builder.build();
     }
 
-    public void writeRoster(final Roster roster) {
-        long start = SystemClock.elapsedRealtime();
-        final Account account = roster.getAccount();
+    public void writeRoster(
+            final Account account, final String version, final List<Contact> contacts) {
+        final long start = SystemClock.elapsedRealtime();
         final SQLiteDatabase db = this.getWritableDatabase();
         db.beginTransaction();
-        for (Contact contact : roster.getContacts()) {
+        for (final Contact contact : contacts) {
             if (contact.getOption(Contact.Options.IN_ROSTER)
                     || contact.hasAvatarOrPresenceName()
                     || contact.getOption(Contact.Options.SYNCED_VIA_OTHER)) {
@@ -1838,7 +1844,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         }
         db.setTransactionSuccessful();
         db.endTransaction();
-        account.setRosterVersion(roster.getVersion());
+        account.setRosterVersion(version);
         updateAccount(account);
         long duration = SystemClock.elapsedRealtime() - start;
         Log.d(

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

@@ -31,6 +31,7 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import im.conversations.android.xmpp.model.stanza.Presence;
+import im.conversations.android.xmpp.model.up.Push;
 import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.util.List;
@@ -320,8 +321,7 @@ public class UnifiedPushBroker {
         service.sendBroadcast(intent);
     }
 
-    public boolean processPushMessage(
-            final Account account, final Jid transport, final Element push) {
+    public boolean processPushMessage(final Account account, final Jid transport, final Push push) {
         final String instance = push.getAttribute("instance");
         final String application = push.getAttribute("application");
         if (Strings.isNullOrEmpty(instance) || Strings.isNullOrEmpty(application)) {

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

@@ -112,7 +112,6 @@ import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.PhoneHelper;
 import eu.siacs.conversations.utils.QuickLoader;
 import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
-import eu.siacs.conversations.utils.ReplacingTaskManager;
 import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
 import eu.siacs.conversations.utils.StringUtils;
@@ -139,6 +138,7 @@ import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 import eu.siacs.conversations.xmpp.mam.MamReference;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+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;
@@ -149,6 +149,7 @@ 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;
 import java.security.cert.CertificateException;
@@ -228,7 +229,6 @@ public class XmppConnectionService extends Service {
             new SerialSingleThreadExecutor("DatabaseReader");
     private final SerialSingleThreadExecutor mNotificationExecutor =
             new SerialSingleThreadExecutor("NotificationExecutor");
-    private final ReplacingTaskManager mRosterSyncTaskManager = new ReplacingTaskManager();
     private final IBinder mBinder = new XmppConnectionBinder();
     private final List<Conversation> conversations = new CopyOnWriteArrayList<>();
     private final IqGenerator mIqGenerator = new IqGenerator(this);
@@ -1173,7 +1173,7 @@ public class XmppConnectionService extends Service {
     }
 
     public boolean processUnifiedPushMessage(
-            final Account account, final Jid transport, final Element push) {
+            final Account account, final Jid transport, final Push push) {
         return unifiedPushBroker.processPushMessage(account, transport, push);
     }
 
@@ -1750,7 +1750,7 @@ public class XmppConnectionService extends Service {
         int activeAccounts = 0;
         for (final Account account : accounts) {
             if (account.isConnectionEnabled()) {
-                databaseBackend.writeRoster(account.getRoster());
+                account.getXmppConnection().getManager(RosterManager.class).writeToDatabase();
                 activeAccounts++;
             }
             if (account.getXmppConnection() != null) {
@@ -2529,10 +2529,9 @@ public class XmppConnectionService extends Service {
                         }
                         Log.d(Config.LOGTAG, "restoring roster...");
                         for (final Account account : accounts) {
-                            databaseBackend.readRoster(account.getRoster());
                             account.initAccountServices(
-                                    XmppConnectionService
-                                            .this); // roster needs to be loaded at this stage
+                                    this); // roster needs to be loaded at this stage
+                            account.getXmppConnection().getManager(RosterManager.class).restore();
                         }
                         getBitmapCache().evictAll();
                         loadPhoneContacts();
@@ -2583,9 +2582,13 @@ public class XmppConnectionService extends Service {
                 () -> {
                     final Map<Jid, JabberIdContact> contacts = JabberIdContact.load(this);
                     Log.d(Config.LOGTAG, "start merging phone contacts with roster");
+                    // TODO if we do this merge this only on enabled accounts we need to trigger
+                    // this upon enable
                     for (final Account account : accounts) {
-                        final List<Contact> withSystemAccounts =
-                                account.getRoster().getWithSystemAccounts(JabberIdContact.class);
+                        final var remaining =
+                                new ArrayList<>(
+                                        account.getRoster()
+                                                .getWithSystemAccounts(JabberIdContact.class));
                         for (final JabberIdContact jidContact : contacts.values()) {
                             final Contact contact =
                                     account.getRoster().getContact(jidContact.getJid());
@@ -2593,9 +2596,9 @@ public class XmppConnectionService extends Service {
                             if (needsCacheClean) {
                                 getAvatarService().clear(contact);
                             }
-                            withSystemAccounts.remove(contact);
+                            remaining.remove(contact);
                         }
-                        for (final Contact contact : withSystemAccounts) {
+                        for (final Contact contact : remaining) {
                             boolean needsCacheClean =
                                     contact.unsetPhoneContact(JabberIdContact.class);
                             if (needsCacheClean) {
@@ -2611,11 +2614,6 @@ public class XmppConnectionService extends Service {
                 });
     }
 
-    public void syncRoster(final Account account) {
-        mRosterSyncTaskManager.execute(
-                account, () -> databaseBackend.writeRoster(account.getRoster()));
-    }
-
     public List<Conversation> getConversations() {
         return this.conversations;
     }
@@ -3260,7 +3258,6 @@ public class XmppConnectionService extends Service {
             if (CallIntegration.hasSystemFeature(this)) {
                 CallIntegrationConnectionService.unregisterPhoneAccount(this, account);
             }
-            this.mRosterSyncTaskManager.clear(account);
             updateAccountUi();
             mNotificationService.updateErrorNotification();
             syncEnabledAccountSetting();
@@ -4693,6 +4690,7 @@ public class XmppConnectionService extends Service {
         updateConversationUi();
     }
 
+    // TODO move this to RosterManager
     public void syncDirtyContacts(Account account) {
         for (Contact contact : account.getRoster().getContacts()) {
             if (contact.getOption(Contact.Options.DIRTY_PUSH)) {
@@ -4741,7 +4739,7 @@ public class XmppConnectionService extends Service {
                         account, mPresenceGenerator.requestPresenceUpdatesFrom(contact, preAuth));
             }
         } else {
-            syncRoster(contact.getAccount());
+            account.getXmppConnection().getManager(RosterManager.class).writeToDatabaseAsync();
         }
     }
 
@@ -5127,7 +5125,9 @@ public class XmppConnectionService extends Service {
                                     final Contact contact =
                                             account.getRoster().getContact(avatar.owner);
                                     contact.setAvatar(avatar);
-                                    syncRoster(account);
+                                    account.getXmppConnection()
+                                            .getManager(RosterManager.class)
+                                            .writeToDatabaseAsync();
                                     getAvatarService().clear(contact);
                                     updateConversationUi();
                                     updateRosterUi();
@@ -5202,7 +5202,9 @@ public class XmppConnectionService extends Service {
                                         final Contact contact =
                                                 account.getRoster().getContact(avatar.owner);
                                         contact.setAvatar(avatar, previouslyOmittedPepFetch);
-                                        syncRoster(account);
+                                        account.getXmppConnection()
+                                                .getManager(RosterManager.class)
+                                                .writeToDatabaseAsync();
                                         getAvatarService().clear(contact);
                                         updateRosterUi();
                                     }
@@ -5227,7 +5229,9 @@ public class XmppConnectionService extends Service {
                                                         account.getRoster()
                                                                 .getContact(user.getRealJid());
                                                 contact.setAvatar(avatar);
-                                                syncRoster(account);
+                                                account.getXmppConnection()
+                                                        .getManager(RosterManager.class)
+                                                        .writeToDatabaseAsync();
                                                 getAvatarService().clear(contact);
                                                 updateRosterUi();
                                             }
@@ -5329,16 +5333,7 @@ public class XmppConnectionService extends Service {
     private void reconnectAccount(
             final Account account, final boolean force, final boolean interactive) {
         synchronized (account) {
-            final XmppConnection existingConnection = account.getXmppConnection();
-            final XmppConnection connection;
-            if (existingConnection != null) {
-                connection = existingConnection;
-            } else if (account.isConnectionEnabled()) {
-                connection = createConnection(account);
-                account.setXmppConnection(connection);
-            } else {
-                return;
-            }
+            final XmppConnection connection = account.getXmppConnection();
             final boolean hasInternet = hasInternetConnection();
             if (account.isConnectionEnabled() && hasInternet) {
                 if (!force) {
@@ -5352,7 +5347,7 @@ public class XmppConnectionService extends Service {
                 scheduleWakeUpCall(Config.CONNECT_DISCO_TIMEOUT, account.getUuid().hashCode());
             } else {
                 disconnect(account, force || account.getTrueStatus().isError() || !hasInternet);
-                account.getRoster().clearPresences();
+                connection.getManager(RosterManager.class).clearPresences();
                 connection.resetEverything();
                 final AxolotlService axolotlService = account.getAxolotlService();
                 if (axolotlService != null) {

src/main/java/eu/siacs/conversations/utils/ReplacingTaskManager.java 🔗

@@ -1,57 +0,0 @@
-/*
- * Copyright (c) 2018, Daniel Gultsch All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without modification,
- * are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright notice, this
- * list of conditions and the following disclaimer.
- *
- * 2. Redistributions in binary form must reproduce the above copyright notice,
- * this list of conditions and the following disclaimer in the documentation and/or
- * other materials provided with the distribution.
- *
- * 3. Neither the name of the copyright holder nor the names of its contributors
- * may be used to endorse or promote products derived from this software without
- * specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
- * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
- * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
- * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
- * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
- * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
- * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package eu.siacs.conversations.utils;
-
-import java.util.HashMap;
-
-import eu.siacs.conversations.entities.Account;
-
-public class ReplacingTaskManager {
-
-	private final HashMap<Account, ReplacingSerialSingleThreadExecutor> executors = new HashMap<>();
-
-	public void execute(final Account account, Runnable runnable) {
-		ReplacingSerialSingleThreadExecutor executor;
-		synchronized (this.executors) {
-			executor = this.executors.get(account);
-			if (executor == null) {
-				executor = new ReplacingSerialSingleThreadExecutor(ReplacingTaskManager.class.getSimpleName());
-				this.executors.put(account, executor);
-			}
-			executor.execute(runnable);
-		}
-	}
-
-	public void clear(Account account) {
-		synchronized (this.executors) {
-			this.executors.remove(account);
-		}
-	}
-}

src/main/java/eu/siacs/conversations/xml/Namespace.java 🔗

@@ -41,6 +41,7 @@ public final class Namespace {
     public static final String SASL_2 = "urn:xmpp:sasl:2";
     public static final String CHANNEL_BINDING = "urn:xmpp:sasl-cb:0";
     public static final String FAST = "urn:xmpp:fast:0";
+    public static final String TIME = "urn:xmpp:time";
     public static final String TLS = "urn:ietf:params:xml:ns:xmpp-tls";
     public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
     public static final String PUBSUB_EVENT = PUBSUB + "#event";

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

@@ -1,13 +1,17 @@
 package eu.siacs.conversations.xmpp;
 
-import android.content.Context;
 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.BlockingManager;
 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.PingManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
+import eu.siacs.conversations.xmpp.manager.RosterManager;
+import eu.siacs.conversations.xmpp.manager.UnifiedPushManager;
 
 public class Managers {
 
@@ -16,12 +20,16 @@ public class Managers {
     }
 
     public static ClassToInstanceMap<AbstractManager> get(
-            final Context context, final XmppConnection connection) {
+            final XmppConnectionService context, final XmppConnection connection) {
         return new ImmutableClassToInstanceMap.Builder<AbstractManager>()
+                .put(BlockingManager.class, new BlockingManager(context, connection))
                 .put(CarbonsManager.class, new CarbonsManager(context, connection))
                 .put(DiscoManager.class, new DiscoManager(context, connection))
+                .put(EntityTimeManager.class, new EntityTimeManager(context, connection))
                 .put(PingManager.class, new PingManager(context, connection))
                 .put(PresenceManager.class, new PresenceManager(context, connection))
+                .put(RosterManager.class, new RosterManager(context, connection))
+                .put(UnifiedPushManager.class, new UnifiedPushManager(context, connection))
                 .build();
     }
 }

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

@@ -72,6 +72,7 @@ import eu.siacs.conversations.xmpp.bind.Bind2;
 import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived;
 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.PingManager;
@@ -224,7 +225,7 @@ public class XmppConnection implements Runnable {
         this.unregisteredIqListener = new IqParser(service, this);
         this.messageListener = new MessageParser(service, this);
         this.bindListener = new BindProcessor(service, this);
-        this.managers = Managers.get(service.getApplicationContext(), this);
+        this.managers = Managers.get(service, this);
     }
 
     private static void fixResource(final Context context, final Account account) {
@@ -2314,6 +2315,7 @@ public class XmppConnection implements Runnable {
     }
 
     private void discoverCommands() {
+        // TODO move result handling into DiscoManager too
         final var future =
                 getManager(DiscoManager.class).commands(Entity.discoItem(account.getDomain()));
         Futures.addCallback(
@@ -2357,9 +2359,9 @@ public class XmppConnection implements Runnable {
     }
 
     private void enableAdvancedStreamFeatures() {
-        if (getFeatures().blocking() && !features.blockListRequested) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": Requesting block list");
-            this.sendIqPacket(getIqGenerator().generateGetBlockList(), unregisteredIqListener);
+        final var blockingManager = getManager(BlockingManager.class);
+        if (blockingManager.hasFeature()) {
+            blockingManager.request();
         }
         for (final OnAdvancedStreamFeaturesLoaded listener :
                 advancedStreamFeaturesLoadedListeners) {
@@ -2751,15 +2753,6 @@ public class XmppConnection implements Runnable {
         return Iterables.getFirst(items, null).getKey();
     }
 
-    public boolean r() {
-        if (getFeatures().sm()) {
-            this.tagWriter.writeStanzaAsync(new Request());
-            return true;
-        } else {
-            return false;
-        }
-    }
-
     public List<String> getMucServersWithholdAccount() {
         final List<String> servers = getMucServers();
         servers.remove(account.getDomain().toString());
@@ -2846,10 +2839,6 @@ public class XmppConnection implements Runnable {
         this.mInteractive = interactive;
     }
 
-    private IqGenerator getIqGenerator() {
-        return mXmppConnectionService.getIqGenerator();
-    }
-
     public void trackOfflineMessageRetrieval(boolean trackOfflineMessageRetrieval) {
         if (trackOfflineMessageRetrieval) {
             getManager(PingManager.class)
@@ -2871,20 +2860,6 @@ public class XmppConnection implements Runnable {
         return this.offlineMessagesRetrieved;
     }
 
-    public void fetchRoster() {
-        final Iq iqPacket = new Iq(Iq.Type.GET);
-        final var version = account.getRosterVersion();
-        if (Strings.isNullOrEmpty(account.getRosterVersion())) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching roster");
-        } else {
-            Log.d(
-                    Config.LOGTAG,
-                    account.getJid().asBareJid() + ": fetching roster version " + version);
-        }
-        iqPacket.query(Namespace.ROSTER).setAttribute("ver", version);
-        sendIqPacket(iqPacket, unregisteredIqListener);
-    }
-
     public void triggerConnectionTimeout() {
         final var duration = getConnectionDuration();
         Log.d(
@@ -2909,6 +2884,15 @@ public class XmppConnection implements Runnable {
         return this.features;
     }
 
+    public boolean fromServer(final Stanza stanza) {
+        final var account = getAccount().getJid();
+        final Jid from = stanza.getFrom();
+        return from == null
+                || from.equals(account.getDomain())
+                || from.equals(account.asBareJid())
+                || from.equals(account);
+    }
+
     private class MyKeyManager implements X509KeyManager {
         @Override
         public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) {

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

@@ -6,6 +6,6 @@ import eu.siacs.conversations.xmpp.XmppConnection;
 public abstract class AbstractManager extends XmppConnection.Delegate {
 
     protected AbstractManager(final Context context, final XmppConnection connection) {
-        super(context, connection);
+        super(context.getApplicationContext(), connection);
     }
 }

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

@@ -0,0 +1,144 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.collect.ImmutableSet;
+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.services.XmppConnectionService;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.blocking.Block;
+import im.conversations.android.xmpp.model.blocking.Blocklist;
+import im.conversations.android.xmpp.model.blocking.Item;
+import im.conversations.android.xmpp.model.blocking.Unblock;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+public class BlockingManager extends AbstractManager {
+
+    private final XmppConnectionService service;
+
+    private final HashSet<Jid> blocklist = new HashSet<>();
+
+    public BlockingManager(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+        // TODO find a way to get rid of XmppConnectionService and use context instead
+        this.service = service;
+    }
+
+    public void request() {
+        final var future = this.connection.sendIqPacket(new Iq(Iq.Type.GET, new Blocklist()));
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Iq result) {
+                        final var blocklist = result.getExtension(Blocklist.class);
+                        if (blocklist == null) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    getAccount().getJid().asBareJid()
+                                            + ": invalid blocklist response");
+                            return;
+                        }
+                        final var addresses = itemsAsAddresses(blocklist.getItems());
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": discovered blocklist with "
+                                        + addresses.size()
+                                        + " items");
+                        setBlocklist(addresses);
+                        removeBlockedConversations(addresses);
+                        service.updateBlocklistUi(OnUpdateBlocklist.Status.BLOCKED);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        Log.w(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": could not retrieve blocklist",
+                                throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public void pushBlock(final Iq request) {
+        if (connection.fromServer(request)) {
+            final var block = request.getExtension(Block.class);
+            final var addresses = itemsAsAddresses(block.getItems());
+            synchronized (this.blocklist) {
+                this.blocklist.addAll(addresses);
+            }
+            this.removeBlockedConversations(addresses);
+            this.service.updateBlocklistUi(OnUpdateBlocklist.Status.BLOCKED);
+            this.connection.sendResultFor(request);
+        } else {
+            this.connection.sendErrorFor(request, Error.Type.AUTH, new Condition.Forbidden());
+        }
+    }
+
+    public void pushUnblock(final Iq request) {
+        if (connection.fromServer(request)) {
+            final var unblock = request.getExtension(Unblock.class);
+            final var address = itemsAsAddresses(unblock.getItems());
+            synchronized (this.blocklist) {
+                this.blocklist.removeAll(address);
+            }
+            this.service.updateBlocklistUi(OnUpdateBlocklist.Status.UNBLOCKED);
+            this.connection.sendResultFor(request);
+        } else {
+            this.connection.sendErrorFor(request, Error.Type.AUTH, new Condition.Forbidden());
+        }
+    }
+
+    private void removeBlockedConversations(final Collection<Jid> addresses) {
+        var removed = false;
+        for (final Jid address : addresses) {
+            removed |= service.removeBlockedConversations(getAccount(), address);
+        }
+        if (removed) {
+            service.updateConversationUi();
+        }
+    }
+
+    public ImmutableSet<Jid> getBlocklist() {
+        synchronized (this.blocklist) {
+            return ImmutableSet.copyOf(this.blocklist);
+        }
+    }
+
+    private void setBlocklist(final Collection<Jid> addresses) {
+        synchronized (this.blocklist) {
+            this.blocklist.clear();
+            this.blocklist.addAll(addresses);
+        }
+    }
+
+    public boolean hasFeature() {
+        return getManager(DiscoManager.class).hasServerFeature(Namespace.BLOCKING);
+    }
+
+    private static Set<Jid> itemsAsAddresses(final Collection<Item> items) {
+        final var builder = new ImmutableSet.Builder<Jid>();
+        for (final var item : items) {
+            final var jid = Jid.Invalid.getNullForInvalid(item.getJid());
+            if (jid == null) {
+                continue;
+            }
+            builder.add(jid);
+        }
+        return builder.build();
+    }
+}

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

@@ -71,7 +71,7 @@ public class DiscoManager extends AbstractManager {
             Collections.singletonList(Namespace.LAST_MESSAGE_CORRECTION);
     private final List<String> PRIVACY_SENSITIVE =
             Collections.singletonList(
-                    "urn:xmpp:time" // XEP-0202: Entity Time leaks time zone
+                    Namespace.TIME // XEP-0202: Entity Time leaks time zone
                     );
     private final List<String> VOIP_NAMESPACES =
             Arrays.asList(

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

@@ -0,0 +1,43 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import eu.siacs.conversations.AppSettings;
+import eu.siacs.conversations.generator.AbstractGenerator;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.time.Time;
+import java.util.Locale;
+import java.util.TimeZone;
+
+public class EntityTimeManager extends AbstractManager {
+
+    public EntityTimeManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void request(final Iq request) {
+        final var appSettings = new AppSettings(this.context);
+        if (appSettings.isUseTor() || getAccount().isOnion()) {
+            this.connection.sendErrorFor(request, Error.Type.AUTH, new Condition.Forbidden());
+            return;
+        }
+        final var time = new Time();
+        final long now = System.currentTimeMillis();
+        time.setUniversalTime(AbstractGenerator.getTimestamp(now));
+        final TimeZone ourTimezone = TimeZone.getDefault();
+        final long offsetSeconds = ourTimezone.getOffset(now) / 1000;
+        final long offsetMinutes = Math.abs((offsetSeconds % 3600) / 60);
+        final long offsetHours = offsetSeconds / 3600;
+        final String hours;
+        if (offsetHours < 0) {
+            hours = String.format(Locale.US, "%03d", offsetHours);
+        } else {
+            hours = String.format(Locale.US, "%02d", offsetHours);
+        }
+        String minutes = String.format(Locale.US, "%02d", offsetMinutes);
+        time.setTimeZoneOffset(hours + ":" + minutes);
+        this.connection.sendResultFor(request, time);
+    }
+}

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

@@ -0,0 +1,276 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+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.MoreExecutors;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.android.AbstractPhoneContact;
+import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Roster;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
+import im.conversations.android.xmpp.model.roster.Item;
+import im.conversations.android.xmpp.model.roster.Query;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+public class RosterManager extends AbstractManager implements Roster {
+
+    private final ReplacingSerialSingleThreadExecutor dbExecutor =
+            new ReplacingSerialSingleThreadExecutor(RosterManager.class.getName());
+
+    private final List<Contact> contacts = new ArrayList<>();
+    private String version;
+
+    private final XmppConnectionService service;
+
+    public RosterManager(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+        this.version = getAccount().getRosterVersion();
+        ;
+        this.service = service;
+    }
+
+    public void request() {
+        final var iq = new Iq(Iq.Type.GET);
+        final var query = iq.addExtension(new Query());
+        final var version = this.version;
+        if (version != null) {
+            Log.d(
+                    Config.LOGTAG,
+                    getAccount().getJid().asBareJid() + ": requesting roster version " + version);
+            query.setVersion(version);
+        } else {
+            Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + " requesting roster");
+        }
+        final var future = connection.sendIqPacket(iq);
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Iq result) {
+                        final var query = result.getExtension(Query.class);
+                        if (query == null) {
+                            // No query in result means further modifications are sent via pushes
+                            return;
+                        }
+                        final var version = query.getVersion();
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": received full roster (version="
+                                        + version
+                                        + ")");
+                        final var items = query.getItems();
+                        // In a roster result (Section 2.1.4), the client MUST ignore values of the
+                        // 'subscription'
+                        // attribute other than "none", "to", "from", or "both".
+                        final var validItems =
+                                Collections2.filter(
+                                        items,
+                                        i ->
+                                                Item.RESULT_SUBSCRIPTIONS.contains(
+                                                                i.getSubscription())
+                                                        && Objects.nonNull(i.getJid()));
+
+                        setRosterItems(version, validItems);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid() + ": could not fetch roster",
+                                throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void setRosterItems(final String version, final Collection<Item> items) {
+        synchronized (this.contacts) {
+            markAllAsNotInRoster();
+            for (final var item : items) {
+                processRosterItem(item);
+            }
+            this.version = version;
+        }
+        this.triggerUiUpdates();
+        this.writeToDatabaseAsync();
+    }
+
+    private void modifyRosterItems(final String version, final Collection<Item> items) {
+        synchronized (this.contacts) {
+            for (final var item : items) {
+                processRosterItem(item);
+            }
+            this.version = version;
+        }
+        this.triggerUiUpdates();
+        this.writeToDatabaseAsync();
+    }
+
+    private void triggerUiUpdates() {
+        this.service.updateConversationUi();
+        this.service.updateRosterUi();
+        this.service.getShortcutService().refresh();
+    }
+
+    public void push(final Iq packet) {
+        if (connection.fromServer(packet)) {
+            final var query = packet.getExtension(Query.class);
+            final var version = query.getVersion();
+            modifyRosterItems(version, query.getItems());
+            Log.d(
+                    Config.LOGTAG,
+                    getAccount().getJid() + ": received roster push (version=" + version + ")");
+        } else {
+            connection.sendErrorFor(packet, Error.Type.AUTH, new Condition.Forbidden());
+        }
+    }
+
+    private void processRosterItem(final Item item) {
+        // this is verbatim the original code from IqParser.
+        // TODO there are likely better ways to handle roster management
+        final Jid jid = Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("jid"));
+        if (jid == null) {
+            return;
+        }
+        final var name = item.getItemName();
+        final var subscription = item.getSubscription();
+        // getContactInternal is not synchronized because all access to processRosterItem is
+        final var contact = getContactInternal(jid);
+        boolean bothPre =
+                contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
+        if (!contact.getOption(Contact.Options.DIRTY_PUSH)) {
+            contact.setServerName(name);
+            contact.parseGroupsFromElement(item);
+        }
+        if (subscription == Item.Subscription.REMOVE) {
+            contact.resetOption(Contact.Options.IN_ROSTER);
+            contact.resetOption(Contact.Options.DIRTY_DELETE);
+            contact.resetOption(Contact.Options.PREEMPTIVE_GRANT);
+        } else {
+            contact.setOption(Contact.Options.IN_ROSTER);
+            contact.resetOption(Contact.Options.DIRTY_PUSH);
+            // TODO use subscription; and set asking separately
+            contact.parseSubscriptionFromElement(item);
+        }
+        boolean both =
+                contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
+        if ((both != bothPre) && both) {
+            final var account = getAccount();
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": gained mutual presence subscription with "
+                            + contact.getJid());
+            final var axolotlService = account.getAxolotlService();
+            if (axolotlService != null) {
+                axolotlService.clearErrorsInFetchStatusMap(contact.getJid());
+            }
+        }
+        service.getAvatarService().clear(contact);
+    }
+
+    @Override
+    @NonNull
+    public Contact getContact(@NonNull final Jid jid) {
+        synchronized (this.contacts) {
+            return this.getContactInternal(jid);
+        }
+    }
+
+    @NonNull
+    public Contact getContactInternal(@NonNull final Jid jid) {
+        final var existing =
+                Iterables.find(this.contacts, c -> c.getJid().equals(jid.asBareJid()), null);
+        if (existing != null) {
+            return existing;
+        }
+        final var contact = new Contact(jid.asBareJid());
+        contact.setAccount(getAccount());
+        this.contacts.add(contact);
+        return contact;
+    }
+
+    @Override
+    @Nullable
+    public Contact getContactFromContactList(@NonNull final Jid jid) {
+        synchronized (this.contacts) {
+            final var contact =
+                    Iterables.find(this.contacts, c -> c.getJid().equals(jid.asBareJid()));
+            if (contact != null && contact.showInContactList()) {
+                return contact;
+            } else {
+                return null;
+            }
+        }
+    }
+
+    @Override
+    public List<Contact> getContacts() {
+        synchronized (this.contacts) {
+            return ImmutableList.copyOf(this.contacts);
+        }
+    }
+
+    @Override
+    public ImmutableList<Contact> getWithSystemAccounts(
+            final Class<? extends AbstractPhoneContact> clazz) {
+        final int option = Contact.getOption(clazz);
+        synchronized (this.contacts) {
+            return ImmutableList.copyOf(
+                    Collections2.filter(this.contacts, c -> c.getOption(option)));
+        }
+    }
+
+    public void clearPresences() {
+        synchronized (this.contacts) {
+            for (final var contact : this.contacts) {
+                contact.clearPresences();
+            }
+        }
+    }
+
+    private void markAllAsNotInRoster() {
+        for (final var contact : this.contacts) {
+            contact.resetOption(Contact.Options.IN_ROSTER);
+        }
+    }
+
+    public void restore() {
+        synchronized (this.contacts) {
+            this.contacts.clear();
+            this.contacts.addAll(getDatabase().readRoster(getAccount()));
+        }
+    }
+
+    public void writeToDatabaseAsync() {
+        this.dbExecutor.execute(this::writeToDatabase);
+    }
+
+    public void writeToDatabase() {
+        final var account = getAccount();
+        final List<Contact> contacts;
+        final String version;
+        synchronized (this.contacts) {
+            contacts = ImmutableList.copyOf(this.contacts);
+            version = this.version;
+        }
+        getDatabase().writeRoster(account, version, contacts);
+    }
+}

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

@@ -0,0 +1,34 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.up.Push;
+
+public class UnifiedPushManager extends AbstractManager {
+
+    private final XmppConnectionService service;
+
+    public UnifiedPushManager(
+            final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
+        this.service = service;
+    }
+
+    public void push(final Iq packet) {
+        final Jid transport = packet.getFrom();
+        final var push = packet.getOnlyExtension(Push.class);
+        if (push == null || transport == null) {
+            connection.sendErrorFor(packet, Error.Type.MODIFY, new Condition.BadRequest());
+            return;
+        }
+        if (service.processUnifiedPushMessage(getAccount(), transport, push)) {
+            connection.sendResultFor(packet);
+        } else {
+            connection.sendErrorFor(packet, Error.Type.CANCEL, new Condition.ItemNotFound());
+        }
+    }
+}

src/main/java/im/conversations/android/xmpp/model/blocking/Block.java 🔗

@@ -2,6 +2,7 @@ package im.conversations.android.xmpp.model.blocking;
 
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
 
 @XmlElement
 public class Block extends Extension {
@@ -9,4 +10,8 @@ public class Block extends Extension {
     public Block() {
         super(Block.class);
     }
+
+    public Collection<Item> getItems() {
+        return this.getExtensions(Item.class);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/blocking/Blocklist.java 🔗

@@ -2,10 +2,15 @@ package im.conversations.android.xmpp.model.blocking;
 
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
 
 @XmlElement
 public class Blocklist extends Extension {
     public Blocklist() {
         super(Blocklist.class);
     }
+
+    public Collection<Item> getItems() {
+        return this.getExtensions(Item.class);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/blocking/Unblock.java 🔗

@@ -2,6 +2,7 @@ package im.conversations.android.xmpp.model.blocking;
 
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
 
 @XmlElement
 public class Unblock extends Extension {
@@ -9,4 +10,8 @@ public class Unblock extends Extension {
     public Unblock() {
         super(Unblock.class);
     }
+
+    public Collection<Item> getItems() {
+        return this.getExtensions(Item.class);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/ibb/Data.java 🔗

@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.ibb;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.ByteContent;
+
+@XmlElement
+public class Data extends InBandByteStream implements ByteContent {
+
+    public Data() {
+        super(Data.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/ibb/InBandByteStream.java 🔗

@@ -0,0 +1,14 @@
+package im.conversations.android.xmpp.model.ibb;
+
+import im.conversations.android.xmpp.model.Extension;
+
+public abstract class InBandByteStream extends Extension {
+
+    public InBandByteStream(Class<? extends InBandByteStream> clazz) {
+        super(clazz);
+    }
+
+    public String getSid() {
+        return this.getAttribute("sid");
+    }
+}

src/main/java/im/conversations/android/xmpp/model/roster/Query.java 🔗

@@ -3,6 +3,7 @@ package im.conversations.android.xmpp.model.roster;
 import eu.siacs.conversations.xml.Namespace;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
 
 @XmlElement(name = "query", namespace = Namespace.ROSTER)
 public class Query extends Extension {
@@ -18,4 +19,8 @@ public class Query extends Extension {
     public String getVersion() {
         return this.getAttribute("ver");
     }
+
+    public Collection<Item> getItems() {
+        return this.getExtensions(Item.class);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/time/Time.java 🔗

@@ -0,0 +1,20 @@
+package im.conversations.android.xmpp.model.time;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Time extends Extension {
+
+    public Time() {
+        super(Time.class);
+    }
+
+    public void setTimeZoneOffset(final String tzo) {
+        this.addExtension(new TimeZoneOffset()).setContent(tzo);
+    }
+
+    public void setUniversalTime(final String utc) {
+        this.addExtension(new UniversalTime()).setContent(utc);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/time/TimeZoneOffset.java 🔗

@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.time;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement(name = "tzo")
+public class TimeZoneOffset extends Extension {
+
+    public TimeZoneOffset() {
+        super(TimeZoneOffset.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/time/UniversalTime.java 🔗

@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.time;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement(name = "utc")
+public class UniversalTime extends Extension {
+
+    public UniversalTime() {
+        super(UniversalTime.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/up/Push.java 🔗

@@ -0,0 +1,13 @@
+package im.conversations.android.xmpp.model.up;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.ByteContent;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Push extends Extension implements ByteContent {
+
+    public Push() {
+        super(Push.class);
+    }
+}

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

@@ -7,6 +7,7 @@ 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.RosterManager;
 import im.conversations.android.xmpp.model.stanza.Iq;
 
 public class BindProcessor extends XmppConnection.Delegate implements Runnable {
@@ -49,7 +50,7 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
             }
         }
 
-        account.getRoster().clearPresences();
+        connection.getManager(RosterManager.class).clearPresences();
         synchronized (account.inProgressConferenceJoins) {
             account.inProgressConferenceJoins.clear();
         }
@@ -59,7 +60,7 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
         service.getJingleConnectionManager().notifyRebound(account);
         service.getQuickConversationsService().considerSyncBackground(false);
 
-        connection.fetchRoster();
+        getManager(RosterManager.class).request();
 
         if (features.bookmarks2()) {
             service.fetchBookmarks2(account);

src/quicksy/java/eu/siacs/conversations/services/QuickConversationsService.java 🔗

@@ -26,6 +26,7 @@ import eu.siacs.conversations.utils.TLSSocketFactory;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.manager.RosterManager;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import io.michaelrocks.libphonenumber.android.Phonenumber;
 import java.io.BufferedWriter;
@@ -407,7 +408,7 @@ public class QuickConversationsService extends AbstractQuickConversationsService
             }
             refresh(account, contacts.values());
             if (!considerSync(account, contacts, forced)) {
-                service.syncRoster(account);
+                account.getXmppConnection().getManager(RosterManager.class).writeToDatabaseAsync();
             }
         }
     }
@@ -422,7 +423,7 @@ public class QuickConversationsService extends AbstractQuickConversationsService
     }
 
     private void refresh(Account account, Collection<PhoneNumberContact> contacts) {
-        for (Contact contact :
+        for (final var contact :
                 account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
             final Uri uri = contact.getSystemAccount();
             if (uri == null) {
@@ -498,9 +499,11 @@ public class QuickConversationsService extends AbstractQuickConversationsService
                         final Element phoneBook =
                                 response.findChild("phone-book", Namespace.SYNCHRONIZATION);
                         if (phoneBook != null) {
-                            final List<Contact> withSystemAccounts =
-                                    account.getRoster()
-                                            .getWithSystemAccounts(PhoneNumberContact.class);
+                            final var remaining =
+                                    new ArrayList<>(
+                                            account.getRoster()
+                                                    .getWithSystemAccounts(
+                                                            PhoneNumberContact.class));
                             for (Entry entry : Entry.ofPhoneBook(phoneBook)) {
                                 final PhoneNumberContact phoneContact =
                                         contacts.get(entry.getNumber());
@@ -514,10 +517,10 @@ public class QuickConversationsService extends AbstractQuickConversationsService
                                     if (needsCacheClean) {
                                         service.getAvatarService().clear(contact);
                                     }
-                                    withSystemAccounts.remove(contact);
+                                    remaining.remove(contact);
                                 }
                             }
-                            for (final Contact contact : withSystemAccounts) {
+                            for (final Contact contact : remaining) {
                                 final boolean needsCacheClean =
                                         contact.unsetPhoneContact(PhoneNumberContact.class);
                                 if (needsCacheClean) {
@@ -539,7 +542,9 @@ public class QuickConversationsService extends AbstractQuickConversationsService
                                         + ": failed to sync contact list with api server");
                     }
                     mRunningSyncJobs.decrementAndGet();
-                    service.syncRoster(account);
+                    account.getXmppConnection()
+                            .getManager(RosterManager.class)
+                            .writeToDatabaseAsync();
                     service.updateRosterUi();
                 });
         return true;

src/test/java/im/conversations/android/xmpp/EntityCapabilitiesTest.java 🔗

@@ -82,7 +82,7 @@ public class EntityCapabilitiesTest {
     }
 
     @Test
-    public void entityCapsOpenFire() throws IOException {
+    public void entityCapsOpenFireOrg() throws IOException {
         final String xml =
                 """
   <iq type="result" xmlns="jabber:client" to="inputmice3@igniterealtime.org/Conversations.cI4W" from="igniterealtime.org" id="L3xl8X8_kzvx">
@@ -206,6 +206,104 @@ public class EntityCapabilitiesTest {
         Assert.assertEquals("Cd91QBSG4JGOCEvRsSz64xeJPMk=", var);
     }
 
+    @Test
+    public void entityCapsOpenFireTestServer() throws IOException {
+        final String xml =
+                """
+<iq type="result" id="779-6" to="jane@example.org" xmlns="jabber:client">
+  <query xmlns="http://jabber.org/protocol/disco#info">
+    <identity category="server" name="Openfire Server" type="im"/>
+    <identity category="pubsub" type="pep"/>
+    <feature var="http://jabber.org/protocol/caps"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-default"/>
+    <feature var="http://jabber.org/protocol/pubsub#purge-nodes"/>
+    <feature var="http://jabber.org/protocol/pubsub#subscription-options"/>
+    <feature var="http://jabber.org/protocol/pubsub#outcast-affiliation"/>
+    <feature var="msgoffline"/>
+    <feature var="jabber:iq:register"/>
+    <feature var="http://jabber.org/protocol/pubsub#delete-nodes"/>
+    <feature var="http://jabber.org/protocol/pubsub#config-node"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-items"/>
+    <feature var="http://jabber.org/protocol/pubsub#auto-create"/>
+    <feature var="http://jabber.org/protocol/pubsub#delete-items"/>
+    <feature var="http://jabber.org/protocol/disco#items"/>
+    <feature var="http://jabber.org/protocol/pubsub#persistent-items"/>
+    <feature var="http://jabber.org/protocol/pubsub#create-and-configure"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-affiliations"/>
+    <feature var="urn:xmpp:time"/>
+    <feature var="http://jabber.org/protocol/pubsub#manage-subscriptions"/>
+    <feature var="urn:xmpp:bookmarks-conversion:0"/>
+    <feature var="http://jabber.org/protocol/offline"/>
+    <feature var="http://jabber.org/protocol/pubsub#auto-subscribe"/>
+    <feature var="http://jabber.org/protocol/pubsub#publish-options"/>
+    <feature var="urn:xmpp:carbons:2"/>
+    <feature var="http://jabber.org/protocol/address"/>
+    <feature var="http://jabber.org/protocol/pubsub#collections"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-subscriptions"/>
+    <feature var="vcard-temp"/>
+    <feature var="http://jabber.org/protocol/pubsub#subscribe"/>
+    <feature var="http://jabber.org/protocol/pubsub#create-nodes"/>
+    <feature var="http://jabber.org/protocol/pubsub#get-pending"/>
+    <feature var="urn:xmpp:blocking"/>
+    <feature var="http://jabber.org/protocol/pubsub#multi-subscribe"/>
+    <feature var="http://jabber.org/protocol/pubsub#presence-notifications"/>
+    <feature var="urn:xmpp:ping"/>
+    <feature var="http://jabber.org/protocol/pubsub#filtered-notifications"/>
+    <feature var="http://jabber.org/protocol/pubsub#item-ids"/>
+    <feature var="http://jabber.org/protocol/pubsub#meta-data"/>
+    <feature var="http://jabber.org/protocol/pubsub#multi-items"/>
+    <feature var="jabber:iq:roster"/>
+    <feature var="http://jabber.org/protocol/pubsub#instant-nodes"/>
+    <feature var="http://jabber.org/protocol/pubsub#modify-affiliations"/>
+    <feature var="http://jabber.org/protocol/pubsub"/>
+    <feature var="http://jabber.org/protocol/pubsub#publisher-affiliation"/>
+    <feature var="http://jabber.org/protocol/pubsub#access-open"/>
+    <feature var="jabber:iq:version"/>
+    <feature var="http://jabber.org/protocol/pubsub#retract-items"/>
+    <feature var="jabber:iq:privacy"/>
+    <feature var="jabber:iq:last"/>
+    <feature var="http://jabber.org/protocol/commands"/>
+    <feature var="http://jabber.org/protocol/pubsub#publish"/>
+    <feature var="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:private"/>
+    <feature var="http://jabber.org/protocol/rsm"/>
+    <x xmlns="jabber:x:data" type="result">
+      <field var="FORM_TYPE" type="hidden">
+        <value>http://jabber.org/network/serverinfo</value>
+      </field>
+      <field var="admin-addresses" type="list-multi">
+        <value>xmpp:admin@example.org</value>
+        <value>mailto:admin@example.com</value>
+      </field>
+    </x>
+    <x xmlns="jabber:x:data" type="result">
+      <field var="FORM_TYPE" type="hidden">
+        <value>urn:xmpp:dataforms:softwareinfo</value>
+      </field>
+      <field type="text-single" var="os">
+        <value>Linux</value>
+      </field>
+      <field type="text-single" var="os_version">
+        <value>6.8.0-59-generic amd64 - Java 21.0.7</value>
+      </field>
+      <field type="text-single" var="software">
+        <value>Openfire</value>
+      </field>
+      <field type="text-single" var="software_version">
+        <value>5.0.0 Alpha</value>
+      </field>
+    </x>
+  </query>
+</iq>
+""";
+        final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
+        assertThat(element, instanceOf(Iq.class));
+        final var iq = (Iq) element;
+        final InfoQuery info = iq.getExtension(InfoQuery.class);
+        final String var = EntityCapabilities.hash(info).encoded();
+        Assert.assertEquals("3wkXXN9QL/i/AyVoHaqaiTT8BFA=", var);
+    }
+
     @Test
     public void caps2() throws IOException {
         final String xml =