RosterManager.java

  1package eu.siacs.conversations.xmpp.manager;
  2
  3import android.util.Log;
  4import androidx.annotation.NonNull;
  5import androidx.annotation.Nullable;
  6import com.google.common.collect.Collections2;
  7import com.google.common.collect.ImmutableList;
  8import com.google.common.util.concurrent.FutureCallback;
  9import com.google.common.util.concurrent.Futures;
 10import com.google.common.util.concurrent.MoreExecutors;
 11import eu.siacs.conversations.Config;
 12import eu.siacs.conversations.android.AbstractPhoneContact;
 13import eu.siacs.conversations.entities.Contact;
 14import eu.siacs.conversations.entities.Roster;
 15import eu.siacs.conversations.services.XmppConnectionService;
 16import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
 17import eu.siacs.conversations.xmpp.Jid;
 18import eu.siacs.conversations.xmpp.XmppConnection;
 19import im.conversations.android.xmpp.model.error.Condition;
 20import im.conversations.android.xmpp.model.error.Error;
 21import im.conversations.android.xmpp.model.roster.Item;
 22import im.conversations.android.xmpp.model.roster.Query;
 23import im.conversations.android.xmpp.model.stanza.Iq;
 24import java.util.Collection;
 25import java.util.HashMap;
 26import java.util.List;
 27import java.util.Map;
 28import java.util.Objects;
 29
 30public class RosterManager extends AbstractManager implements Roster {
 31
 32    private final ReplacingSerialSingleThreadExecutor dbExecutor =
 33            new ReplacingSerialSingleThreadExecutor(RosterManager.class.getName());
 34
 35    private final Map<Jid, Contact> contacts = new HashMap<>();
 36    private String version;
 37
 38    private final XmppConnectionService service;
 39
 40    public RosterManager(final XmppConnectionService service, final XmppConnection connection) {
 41        super(service, connection);
 42        this.version = getAccount().getRosterVersion();
 43        ;
 44        this.service = service;
 45    }
 46
 47    public void request() {
 48        final var iq = new Iq(Iq.Type.GET);
 49        final var query = iq.addExtension(new Query());
 50        final var version = this.version;
 51        if (version != null) {
 52            Log.d(
 53                    Config.LOGTAG,
 54                    getAccount().getJid().asBareJid() + ": requesting roster version " + version);
 55            query.setVersion(version);
 56        } else {
 57            Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + " requesting roster");
 58        }
 59        final var future = connection.sendIqPacket(iq);
 60        Futures.addCallback(
 61                future,
 62                new FutureCallback<>() {
 63                    @Override
 64                    public void onSuccess(final Iq result) {
 65                        final var query = result.getExtension(Query.class);
 66                        if (query == null) {
 67                            // No query in result means further modifications are sent via pushes
 68                            return;
 69                        }
 70                        final var version = query.getVersion();
 71                        Log.d(
 72                                Config.LOGTAG,
 73                                getAccount().getJid().asBareJid()
 74                                        + ": received full roster (version="
 75                                        + version
 76                                        + ")");
 77                        final var items = query.getItems();
 78                        // In a roster result (Section 2.1.4), the client MUST ignore values of the
 79                        // 'subscription'
 80                        // attribute other than "none", "to", "from", or "both".
 81                        final var validItems =
 82                                Collections2.filter(
 83                                        items,
 84                                        i ->
 85                                                Item.RESULT_SUBSCRIPTIONS.contains(
 86                                                                i.getSubscription())
 87                                                        && Objects.nonNull(i.getJid()));
 88
 89                        setRosterItems(version, validItems);
 90                    }
 91
 92                    @Override
 93                    public void onFailure(@NonNull final Throwable throwable) {
 94                        Log.d(
 95                                Config.LOGTAG,
 96                                getAccount().getJid().asBareJid() + ": could not fetch roster",
 97                                throwable);
 98                    }
 99                },
100                MoreExecutors.directExecutor());
101    }
102
103    private void setRosterItems(final String version, final Collection<Item> items) {
104        synchronized (this.contacts) {
105            markAllAsNotInRoster();
106            for (final var item : items) {
107                processRosterItem(item);
108            }
109            this.version = version;
110        }
111        this.triggerUiUpdates();
112        this.writeToDatabaseAsync();
113    }
114
115    private void modifyRosterItems(final String version, final Collection<Item> items) {
116        synchronized (this.contacts) {
117            for (final var item : items) {
118                processRosterItem(item);
119            }
120            this.version = version;
121        }
122        this.triggerUiUpdates();
123        this.writeToDatabaseAsync();
124    }
125
126    private void triggerUiUpdates() {
127        this.service.updateConversationUi();
128        this.service.updateRosterUi(XmppConnectionService.UpdateRosterReason.PUSH);
129        this.service.getShortcutService().refresh();
130    }
131
132    public void push(final Iq packet) {
133        if (connection.fromServer(packet)) {
134            final var query = packet.getExtension(Query.class);
135            final var version = query.getVersion();
136            modifyRosterItems(version, query.getItems());
137            Log.d(
138                    Config.LOGTAG,
139                    getAccount().getJid() + ": received roster push (version=" + version + ")");
140        } else {
141            connection.sendErrorFor(packet, Error.Type.AUTH, new Condition.Forbidden());
142        }
143    }
144
145    private void processRosterItem(final Item item) {
146        // this is verbatim the original code from IqParser.
147        // TODO there are likely better ways to handle roster management
148        final Jid jid = Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("jid"));
149        if (jid == null) {
150            return;
151        }
152        final var name = item.getItemName();
153        final var subscription = item.getSubscription();
154        // getContactInternal is not synchronized because all access to processRosterItem is
155        final var contact = getContactInternal(jid);
156        boolean bothPre =
157                contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
158        if (!contact.getOption(Contact.Options.DIRTY_PUSH)) {
159            contact.setServerName(name);
160            contact.parseGroupsFromElement(item);
161        }
162        if (subscription == Item.Subscription.REMOVE) {
163            contact.resetOption(Contact.Options.IN_ROSTER);
164            contact.resetOption(Contact.Options.DIRTY_DELETE);
165            contact.resetOption(Contact.Options.PREEMPTIVE_GRANT);
166        } else {
167            contact.setOption(Contact.Options.IN_ROSTER);
168            contact.resetOption(Contact.Options.DIRTY_PUSH);
169            // TODO use subscription; and set asking separately
170            contact.parseSubscriptionFromElement(item);
171        }
172        boolean both =
173                contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
174        if ((both != bothPre) && both) {
175            final var account = getAccount();
176            Log.d(
177                    Config.LOGTAG,
178                    account.getJid().asBareJid()
179                            + ": gained mutual presence subscription with "
180                            + contact.getJid());
181            final var axolotlService = account.getAxolotlService();
182            if (axolotlService != null) {
183                axolotlService.clearErrorsInFetchStatusMap(contact.getJid());
184            }
185        }
186        service.getAvatarService().clear(contact);
187    }
188
189    @Override
190    @NonNull
191    public Contact getContact(@NonNull final Jid jid) {
192        synchronized (this.contacts) {
193            return this.getContactInternal(jid);
194        }
195    }
196
197    @NonNull
198    public Contact getContactInternal(@NonNull final Jid jid) {
199        final var existing = this.contacts.get(jid.asBareJid());
200        if (existing != null) {
201            return existing;
202        }
203        final var contact = new Contact(jid.asBareJid());
204        contact.setAccount(getAccount());
205        this.contacts.put(jid.asBareJid(), contact);
206        return contact;
207    }
208
209    @Override
210    @Nullable
211    public Contact getContactFromContactList(@NonNull final Jid jid) {
212        synchronized (this.contacts) {
213            final var contact = this.contacts.get(jid.asBareJid());
214            if (contact != null && contact.showInContactList()) {
215                return contact;
216            } else {
217                return null;
218            }
219        }
220    }
221
222    @Override
223    public List<Contact> getContacts() {
224        synchronized (this.contacts) {
225            return ImmutableList.copyOf(this.contacts.values());
226        }
227    }
228
229    @Override
230    public ImmutableList<Contact> getWithSystemAccounts(
231            final Class<? extends AbstractPhoneContact> clazz) {
232        final int option = Contact.getOption(clazz);
233        synchronized (this.contacts) {
234            return ImmutableList.copyOf(
235                    Collections2.filter(this.contacts.values(), c -> c.getOption(option)));
236        }
237    }
238
239    public void clearPresences() {
240        synchronized (this.contacts) {
241            for (final var contact : this.contacts.values()) {
242                contact.clearPresences();
243            }
244        }
245    }
246
247    private void markAllAsNotInRoster() {
248        for (final var contact : this.contacts.values()) {
249            contact.resetOption(Contact.Options.IN_ROSTER);
250        }
251    }
252
253    public void restore() {
254        synchronized (this.contacts) {
255            this.contacts.clear();
256            this.contacts.putAll(getDatabase().readRoster(getAccount()));
257        }
258    }
259
260    public void writeToDatabaseAsync() {
261        this.dbExecutor.execute(this::writeToDatabase);
262    }
263
264    public void writeToDatabase() {
265        final var account = getAccount();
266        final List<Contact> contacts;
267        final String version;
268        synchronized (this.contacts) {
269            contacts = ImmutableList.copyOf(this.contacts.values());
270            version = this.version;
271        }
272        getDatabase().writeRoster(account, version, contacts);
273        context.unregisterPhoneAccounts(account);
274        try { Thread.sleep(500); } catch (InterruptedException e) { }
275    }
276
277    public void syncDirtyContacts() {
278        synchronized (this.contacts) {
279            for (final var contact : this.contacts.values()) {
280                if (contact.getOption(Contact.Options.DIRTY_PUSH)) {
281                    addRosterItem(contact, null);
282                }
283                if (contact.getOption(Contact.Options.DIRTY_DELETE)) {
284                    deleteRosterItem(contact);
285                }
286            }
287        }
288    }
289
290    public void addRosterItem(final Contact contact, final String preAuth) {
291        final var address = contact.getJid().asBareJid();
292        contact.resetOption(Contact.Options.DIRTY_DELETE);
293        contact.setOption(Contact.Options.DIRTY_PUSH);
294        // sync the 'dirty push' flag to disk in case we are offline
295        this.writeToDatabaseAsync();
296        final boolean ask = contact.getOption(Contact.Options.ASKING);
297        final boolean sendUpdates =
298                contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)
299                        && contact.getOption(Contact.Options.PREEMPTIVE_GRANT);
300        final Iq iq = new Iq(Iq.Type.SET);
301        final var query = iq.addExtension(new Query());
302        final var item = query.addExtension(new Item());
303        item.setJid(address);
304        final var serverName = contact.getServerName();
305        if (serverName != null) {
306            item.setItemName(serverName);
307        }
308        item.setGroups(contact.getGroups(false));
309        final var future = this.connection.sendIqPacket(iq);
310        Futures.addCallback(
311                future,
312                new FutureCallback<Iq>() {
313                    @Override
314                    public void onSuccess(Iq result) {
315                        Log.d(
316                                Config.LOGTAG,
317                                getAccount().getJid().asBareJid()
318                                        + ": pushed roster item "
319                                        + address);
320                    }
321
322                    @Override
323                    public void onFailure(@NonNull Throwable t) {
324                        Log.d(
325                                Config.LOGTAG,
326                                getAccount().getJid().asBareJid()
327                                        + ": could not push roster item "
328                                        + address,
329                                t);
330                    }
331                },
332                MoreExecutors.directExecutor());
333        if (sendUpdates) {
334            getManager(PresenceManager.class).subscribed(contact.getJid().asBareJid());
335        }
336        if (ask) {
337            getManager(PresenceManager.class).subscribe(contact.getJid().asBareJid(), preAuth);
338        }
339    }
340
341    public void deleteRosterItem(final Contact contact) {
342        final var address = contact.getJid().asBareJid();
343        contact.resetOption(Contact.Options.PREEMPTIVE_GRANT);
344        contact.resetOption(Contact.Options.DIRTY_PUSH);
345        contact.setOption(Contact.Options.DIRTY_DELETE);
346        this.writeToDatabaseAsync();
347        final Iq iq = new Iq(Iq.Type.SET);
348        final var query = iq.addExtension(new Query());
349        final var item = query.addExtension(new Item());
350        item.setJid(address);
351        item.setSubscription(Item.Subscription.REMOVE);
352        final var future = this.connection.sendIqPacket(iq);
353        Futures.addCallback(
354                future,
355                new FutureCallback<Iq>() {
356                    @Override
357                    public void onSuccess(final Iq result) {
358                        Log.d(
359                                Config.LOGTAG,
360                                getAccount().getJid().asBareJid()
361                                        + ": removed roster item "
362                                        + address);
363                    }
364
365                    @Override
366                    public void onFailure(final @NonNull Throwable t) {
367                        Log.d(
368                                Config.LOGTAG,
369                                getAccount().getJid().asBareJid()
370                                        + ": could not remove roster item "
371                                        + address,
372                                t);
373                    }
374                },
375                MoreExecutors.directExecutor());
376    }
377}