MultiUserChatManager.java

  1package eu.siacs.conversations.xmpp.manager;
  2
  3import android.util.Log;
  4import androidx.annotation.NonNull;
  5import com.google.common.base.Strings;
  6import com.google.common.collect.Collections2;
  7import com.google.common.collect.ImmutableList;
  8import com.google.common.collect.ImmutableMap;
  9import com.google.common.collect.Iterables;
 10import com.google.common.util.concurrent.FutureCallback;
 11import com.google.common.util.concurrent.Futures;
 12import com.google.common.util.concurrent.ListenableFuture;
 13import com.google.common.util.concurrent.MoreExecutors;
 14import com.google.common.util.concurrent.SettableFuture;
 15import de.gultsch.common.FutureMerger;
 16import eu.siacs.conversations.Config;
 17import eu.siacs.conversations.entities.Account;
 18import eu.siacs.conversations.entities.Bookmark;
 19import eu.siacs.conversations.entities.Conversation;
 20import eu.siacs.conversations.entities.Conversational;
 21import eu.siacs.conversations.entities.MucOptions;
 22import eu.siacs.conversations.services.XmppConnectionService;
 23import eu.siacs.conversations.utils.CryptoHelper;
 24import eu.siacs.conversations.utils.StringUtils;
 25import eu.siacs.conversations.xml.Namespace;
 26import eu.siacs.conversations.xmpp.Jid;
 27import eu.siacs.conversations.xmpp.XmppConnection;
 28import im.conversations.android.xmpp.Entity;
 29import im.conversations.android.xmpp.IqErrorException;
 30import im.conversations.android.xmpp.model.Extension;
 31import im.conversations.android.xmpp.model.conference.DirectInvite;
 32import im.conversations.android.xmpp.model.data.Data;
 33import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 34import im.conversations.android.xmpp.model.error.Condition;
 35import im.conversations.android.xmpp.model.hints.NoCopy;
 36import im.conversations.android.xmpp.model.hints.NoStore;
 37import im.conversations.android.xmpp.model.jabber.Subject;
 38import im.conversations.android.xmpp.model.muc.Affiliation;
 39import im.conversations.android.xmpp.model.muc.History;
 40import im.conversations.android.xmpp.model.muc.MultiUserChat;
 41import im.conversations.android.xmpp.model.muc.Password;
 42import im.conversations.android.xmpp.model.muc.Role;
 43import im.conversations.android.xmpp.model.muc.admin.Item;
 44import im.conversations.android.xmpp.model.muc.admin.MucAdmin;
 45import im.conversations.android.xmpp.model.muc.owner.Destroy;
 46import im.conversations.android.xmpp.model.muc.owner.MucOwner;
 47import im.conversations.android.xmpp.model.muc.user.Invite;
 48import im.conversations.android.xmpp.model.muc.user.MucUser;
 49import im.conversations.android.xmpp.model.pgp.Signed;
 50import im.conversations.android.xmpp.model.stanza.Iq;
 51import im.conversations.android.xmpp.model.stanza.Message;
 52import im.conversations.android.xmpp.model.stanza.Presence;
 53import im.conversations.android.xmpp.model.vcard.update.VCardUpdate;
 54import java.util.Arrays;
 55import java.util.Collection;
 56import java.util.Collections;
 57import java.util.HashSet;
 58import java.util.List;
 59import java.util.Map;
 60import java.util.Set;
 61
 62public class MultiUserChatManager extends AbstractManager {
 63
 64    private final XmppConnectionService service;
 65
 66    private final Set<Conversation> inProgressConferenceJoins = new HashSet<>();
 67    private final Set<Conversation> inProgressConferencePings = new HashSet<>();
 68
 69    public MultiUserChatManager(final XmppConnectionService service, XmppConnection connection) {
 70        super(service.getApplicationContext(), connection);
 71        this.service = service;
 72    }
 73
 74    public ListenableFuture<Void> join(final Conversation conversation) {
 75        return join(conversation, true);
 76    }
 77
 78    private ListenableFuture<Void> join(
 79            final Conversation conversation, final boolean autoPushConfiguration) {
 80        final var account = getAccount();
 81        synchronized (this.inProgressConferenceJoins) {
 82            this.inProgressConferenceJoins.add(conversation);
 83        }
 84        if (Config.MUC_LEAVE_BEFORE_JOIN) {
 85            unavailable(conversation);
 86        }
 87        conversation.resetMucOptions();
 88        conversation.getMucOptions().setAutoPushConfiguration(autoPushConfiguration);
 89        conversation.setHasMessagesLeftOnServer(false);
 90        final var disco = fetchDiscoInfo(conversation);
 91
 92        final var caughtDisco =
 93                Futures.catchingAsync(
 94                        disco,
 95                        IqErrorException.class,
 96                        ex -> {
 97                            if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
 98                                return Futures.immediateFailedFuture(
 99                                        new IllegalStateException(
100                                                "conversation got archived before disco returned"));
101                            }
102                            Log.d(Config.LOGTAG, "error fetching disco#info", ex);
103                            final var iqError = ex.getError();
104                            if (iqError != null
105                                    && iqError.getCondition()
106                                            instanceof Condition.RemoteServerNotFound) {
107                                synchronized (this.inProgressConferenceJoins) {
108                                    this.inProgressConferenceJoins.remove(conversation);
109                                }
110                                conversation
111                                        .getMucOptions()
112                                        .setError(MucOptions.Error.SERVER_NOT_FOUND);
113                                service.updateConversationUi();
114                                return Futures.immediateFailedFuture(ex);
115                            } else {
116                                return Futures.immediateFuture(new InfoQuery());
117                            }
118                        },
119                        MoreExecutors.directExecutor());
120
121        return Futures.transform(
122                caughtDisco,
123                v -> {
124                    checkConfigurationSendPresenceFetchHistory(conversation);
125                    return null;
126                },
127                MoreExecutors.directExecutor());
128    }
129
130    public ListenableFuture<Void> joinFollowingInvite(final Conversation conversation) {
131        // TODO this special treatment is probably unnecessary; just always make sure the bookmark
132        // exists
133        return Futures.transform(
134                join(conversation),
135                v -> {
136                    // we used to do this only for private groups
137                    final Bookmark bookmark = conversation.getBookmark();
138                    if (bookmark != null) {
139                        if (bookmark.autojoin()) {
140                            return null;
141                        }
142                        bookmark.setAutojoin(true);
143                        getManager(BookmarkManager.class).create(bookmark);
144                    } else {
145                        getManager(BookmarkManager.class).save(conversation, null);
146                    }
147                    return null;
148                },
149                MoreExecutors.directExecutor());
150    }
151
152    private void checkConfigurationSendPresenceFetchHistory(final Conversation conversation) {
153
154        Account account = conversation.getAccount();
155        final MucOptions mucOptions = conversation.getMucOptions();
156
157        if (mucOptions.nonanonymous()
158                && !mucOptions.membersOnly()
159                && !conversation.getBooleanAttribute("accept_non_anonymous", false)) {
160            synchronized (this.inProgressConferenceJoins) {
161                this.inProgressConferenceJoins.remove(conversation);
162            }
163            mucOptions.setError(MucOptions.Error.NON_ANONYMOUS);
164            service.updateConversationUi();
165            return;
166        }
167
168        final Jid joinJid = mucOptions.getSelf().getFullJid();
169        Log.d(
170                Config.LOGTAG,
171                account.getJid().asBareJid().toString()
172                        + ": joining conversation "
173                        + joinJid.toString());
174
175        final var x = new MultiUserChat();
176
177        if (mucOptions.getPassword() != null) {
178            x.addExtension(new Password(mucOptions.getPassword()));
179        }
180
181        final var history = x.addExtension(new History());
182
183        if (mucOptions.mamSupport()) {
184            // Use MAM instead of the limited muc history to get history
185            history.setMaxStanzas(0);
186        } else {
187            // Fallback to muc history
188            history.setSince(conversation.getLastMessageTransmitted().getTimestamp());
189        }
190        available(joinJid, mucOptions.nonanonymous(), x);
191        if (!joinJid.equals(conversation.getJid())) {
192            conversation.setContactJid(joinJid);
193            getDatabase().updateConversation(conversation);
194        }
195
196        if (mucOptions.mamSupport()) {
197            this.service.getMessageArchiveService().catchupMUC(conversation);
198        }
199        if (mucOptions.isPrivateAndNonAnonymous()) {
200            fetchMembers(conversation);
201        }
202        synchronized (this.inProgressConferenceJoins) {
203            this.inProgressConferenceJoins.remove(conversation);
204            this.service.sendUnsentMessages(conversation);
205        }
206    }
207
208    public ListenableFuture<Conversation> createPrivateGroupChat(
209            final String name, final Collection<Jid> addresses) {
210        final var service = getService();
211        if (service == null) {
212            return Futures.immediateFailedFuture(new IllegalStateException("No MUC service found"));
213        }
214        final var address = Jid.ofLocalAndDomain(CryptoHelper.pronounceable(), service);
215        final var conversation =
216                this.service.findOrCreateConversation(getAccount(), address, true, false, true);
217        final var join = this.join(conversation, false);
218        final var configured =
219                Futures.transformAsync(
220                        join,
221                        v -> {
222                            final var options =
223                                    configWithName(defaultGroupChatConfiguration(), name);
224                            return pushConfiguration(conversation, options);
225                        },
226                        MoreExecutors.directExecutor());
227
228        // TODO add catching to 'configured' to archive the chat again
229
230        return Futures.transform(
231                configured,
232                c -> {
233                    for (var invitee : addresses) {
234                        this.service.invite(conversation, invitee);
235                    }
236                    final var account = getAccount();
237                    for (final var resource :
238                            account.getSelfContact().getPresences().toResourceArray()) {
239                        Jid other = getAccount().getJid().withResource(resource);
240                        Log.d(
241                                Config.LOGTAG,
242                                account.getJid().asBareJid()
243                                        + ": sending direct invite to "
244                                        + other);
245                        this.service.directInvite(conversation, other);
246                    }
247                    getManager(BookmarkManager.class).save(conversation, name);
248                    return conversation;
249                },
250                MoreExecutors.directExecutor());
251    }
252
253    public ListenableFuture<Conversation> createPublicChannel(
254            final Jid address, final String name) {
255
256        final var conversation =
257                this.service.findOrCreateConversation(getAccount(), address, true, false, true);
258
259        final var join = this.join(conversation, false);
260        final var configuration =
261                Futures.transformAsync(
262                        join,
263                        v -> {
264                            final var options = configWithName(defaultChannelConfiguration(), name);
265                            return pushConfiguration(conversation, options);
266                        },
267                        MoreExecutors.directExecutor());
268
269        // TODO mostly ignore configuration error
270
271        return Futures.transform(
272                configuration,
273                v -> {
274                    getManager(BookmarkManager.class).save(conversation, name);
275                    return conversation;
276                },
277                MoreExecutors.directExecutor());
278    }
279
280    public void leave(final Conversation conversation) {
281        final var mucOptions = conversation.getMucOptions();
282        mucOptions.setOffline();
283        getManager(DiscoManager.class).clear(conversation.getJid().asBareJid());
284        unavailable(conversation);
285    }
286
287    public void handlePresence(final Presence presence) {}
288
289    public void handleStatusMessage(final Message message) {
290        final var from = Jid.Invalid.getNullForInvalid(message.getFrom());
291        final var mucUser = message.getExtension(MucUser.class);
292        if (from == null || from.isFullJid() || mucUser == null) {
293            return;
294        }
295        final var conversation = this.service.find(getAccount(), from);
296        if (conversation == null || conversation.getMode() != Conversation.MODE_MULTI) {
297            return;
298        }
299        for (final var status : mucUser.getStatus()) {
300            handleStatusCode(conversation, status);
301        }
302        final var item = mucUser.getItem();
303        if (item == null) {
304            return;
305        }
306        final var user = itemToUser(conversation, item, null);
307        this.handleAffiliationChange(conversation, user);
308    }
309
310    private void handleAffiliationChange(
311            final Conversation conversation, final MucOptions.User user) {
312        final var account = getAccount();
313        Log.d(
314                Config.LOGTAG,
315                account.getJid()
316                        + ": changing affiliation for "
317                        + user.getRealJid()
318                        + " to "
319                        + user.getAffiliation()
320                        + " in "
321                        + conversation.getJid().asBareJid());
322        if (user.realJidMatchesAccount()) {
323            return;
324        }
325        final var mucOptions = conversation.getMucOptions();
326        final boolean isNew = mucOptions.updateUser(user);
327        final var avatarService = this.service.getAvatarService();
328        if (Strings.isNullOrEmpty(mucOptions.getAvatar())) {
329            avatarService.clear(mucOptions);
330        }
331        avatarService.clear(user);
332        this.service.updateMucRosterUi();
333        this.service.updateConversationUi();
334        if (user.ranks(Affiliation.MEMBER)) {
335            fetchDeviceIdsIfNeeded(isNew, user);
336        } else {
337            final var jid = user.getRealJid();
338            final var cryptoTargets = conversation.getAcceptedCryptoTargets();
339            if (cryptoTargets.remove(user.getRealJid())) {
340                Log.d(
341                        Config.LOGTAG,
342                        account.getJid().asBareJid()
343                                + ": removed "
344                                + jid
345                                + " from crypto targets of "
346                                + conversation.getName());
347                conversation.setAcceptedCryptoTargets(cryptoTargets);
348                getDatabase().updateConversation(conversation);
349            }
350        }
351    }
352
353    private void fetchDeviceIdsIfNeeded(final boolean isNew, final MucOptions.User user) {
354        final var contact = user.getContact();
355        final var mucOptions = user.getMucOptions();
356        final var axolotlService = connection.getAxolotlService();
357        if (isNew
358                && user.getRealJid() != null
359                && mucOptions.isPrivateAndNonAnonymous()
360                && (contact == null || !contact.mutualPresenceSubscription())
361                && axolotlService.hasEmptyDeviceList(user.getRealJid())) {
362            axolotlService.fetchDeviceIds(user.getRealJid());
363        }
364    }
365
366    private void handleStatusCode(final Conversation conversation, final int status) {
367        if ((status >= 170 && status <= 174) || (status >= 102 && status <= 104)) {
368            Log.d(
369                    Config.LOGTAG,
370                    getAccount().getJid().asBareJid()
371                            + ": fetching disco#info on status code "
372                            + status);
373            getManager(MultiUserChatManager.class).fetchDiscoInfo(conversation);
374        }
375    }
376
377    public ListenableFuture<Void> fetchDiscoInfo(final Conversation conversation) {
378        final var address = conversation.getJid().asBareJid();
379        final var future =
380                connection.getManager(DiscoManager.class).info(Entity.discoItem(address), null);
381        return Futures.transform(
382                future,
383                infoQuery -> {
384                    setDiscoInfo(conversation, infoQuery);
385                    return null;
386                },
387                MoreExecutors.directExecutor());
388    }
389
390    private void setDiscoInfo(final Conversation conversation, final InfoQuery result) {
391        final var account = conversation.getAccount();
392        final var address = conversation.getJid().asBareJid();
393        final var avatarHash =
394                result.getServiceDiscoveryExtension(
395                        Namespace.MUC_ROOM_INFO, "muc#roominfo_avatarhash");
396        if (VCardUpdate.isValidSHA1(avatarHash)) {
397            connection.getManager(AvatarManager.class).handleVCardUpdate(address, avatarHash);
398        }
399        final MucOptions mucOptions = conversation.getMucOptions();
400        final Bookmark bookmark = conversation.getBookmark();
401        final boolean sameBefore =
402                StringUtils.equals(
403                        bookmark == null ? null : bookmark.getBookmarkName(), mucOptions.getName());
404
405        final var hadOccupantId = mucOptions.occupantId();
406        if (mucOptions.updateConfiguration(result)) {
407            Log.d(
408                    Config.LOGTAG,
409                    account.getJid().asBareJid()
410                            + ": muc configuration changed for "
411                            + conversation.getJid().asBareJid());
412            getDatabase().updateConversation(conversation);
413        }
414
415        final var hasOccupantId = mucOptions.occupantId();
416
417        if (!hadOccupantId && hasOccupantId && mucOptions.online()) {
418            final var me = mucOptions.getSelf().getFullJid();
419            Log.d(
420                    Config.LOGTAG,
421                    account.getJid().asBareJid()
422                            + ": gained support for occupant-id in "
423                            + me
424                            + ". resending presence");
425            this.available(me, mucOptions.nonanonymous());
426        }
427
428        if (bookmark != null && (sameBefore || bookmark.getBookmarkName() == null)) {
429            if (bookmark.setBookmarkName(StringUtils.nullOnEmpty(mucOptions.getName()))) {
430                getManager(BookmarkManager.class).create(bookmark);
431            }
432        }
433        this.service.updateConversationUi();
434    }
435
436    public void resendPresence(final Conversation conversation) {
437        final MucOptions mucOptions = conversation.getMucOptions();
438        if (mucOptions.online()) {
439            available(mucOptions.getSelf().getFullJid(), mucOptions.nonanonymous());
440        }
441    }
442
443    private void available(
444            final Jid address, final boolean nonAnonymous, final Extension... extensions) {
445        final var presenceManager = getManager(PresenceManager.class);
446        final var account = getAccount();
447        final String pgpSignature = account.getPgpSignature();
448        if (nonAnonymous && pgpSignature != null) {
449            final String message = account.getPresenceStatusMessage();
450            presenceManager.available(
451                    address, message, combine(extensions, new Signed(pgpSignature)));
452        } else {
453            presenceManager.available(address, extensions);
454        }
455    }
456
457    public void unavailable(final Conversation conversation) {
458        final var mucOptions = conversation.getMucOptions();
459        getManager(PresenceManager.class).unavailable(mucOptions.getSelf().getFullJid());
460    }
461
462    private static Extension[] combine(final Extension[] extensions, final Extension extension) {
463        return new ImmutableList.Builder<Extension>()
464                .addAll(Arrays.asList(extensions))
465                .add(extension)
466                .build()
467                .toArray(new Extension[0]);
468    }
469
470    public ListenableFuture<Void> pushConfiguration(
471            final Conversation conversation, final Map<String, Object> input) {
472        final var address = conversation.getJid().asBareJid();
473        final var configuration = modifyBestInteroperability(input);
474
475        if (configuration.get("muc#roomconfig_whois") instanceof String whois
476                && whois.equals("anyone")) {
477            conversation.setAttribute("accept_non_anonymous", true);
478            getDatabase().updateConversation(conversation);
479        }
480
481        final var future = fetchConfigurationForm(address);
482        return Futures.transformAsync(
483                future,
484                current -> {
485                    final var modified = current.submit(configuration);
486                    return submitConfigurationForm(address, modified);
487                },
488                MoreExecutors.directExecutor());
489    }
490
491    public ListenableFuture<Data> fetchConfigurationForm(final Jid address) {
492        final var iq = new Iq(Iq.Type.GET, new MucOwner());
493        iq.setTo(address);
494        Log.d(Config.LOGTAG, "fetching configuration form: " + iq);
495        return Futures.transform(
496                connection.sendIqPacket(iq),
497                response -> {
498                    final var mucOwner = response.getExtension(MucOwner.class);
499                    if (mucOwner == null) {
500                        throw new IllegalStateException("Missing MucOwner element in response");
501                    }
502                    return mucOwner.getConfiguration();
503                },
504                MoreExecutors.directExecutor());
505    }
506
507    private ListenableFuture<Void> submitConfigurationForm(final Jid address, final Data data) {
508        final var iq = new Iq(Iq.Type.SET);
509        iq.setTo(address);
510        final var mucOwner = iq.addExtension(new MucOwner());
511        mucOwner.addExtension(data);
512        Log.d(Config.LOGTAG, "pushing configuration form: " + iq);
513        return Futures.transform(
514                this.connection.sendIqPacket(iq), response -> null, MoreExecutors.directExecutor());
515    }
516
517    public ListenableFuture<Void> fetchMembers(final Conversation conversation) {
518        final var futures =
519                Collections2.transform(
520                        Arrays.asList(Affiliation.OWNER, Affiliation.ADMIN, Affiliation.MEMBER),
521                        a -> fetchAffiliations(conversation, a));
522        ListenableFuture<List<MucOptions.User>> future = FutureMerger.allAsList(futures);
523        return Futures.transform(
524                future,
525                members -> {
526                    setMembers(conversation, members);
527                    return null;
528                },
529                MoreExecutors.directExecutor());
530    }
531
532    private void setMembers(final Conversation conversation, final List<MucOptions.User> users) {
533        for (final var user : users) {
534            if (user.realJidMatchesAccount()) {
535                continue;
536            }
537            boolean isNew = conversation.getMucOptions().updateUser(user);
538            fetchDeviceIdsIfNeeded(isNew, user);
539        }
540        final var mucOptions = conversation.getMucOptions();
541        final var members = mucOptions.getMembers(true);
542        final var cryptoTargets = conversation.getAcceptedCryptoTargets();
543        boolean changed = false;
544        for (final var iterator = cryptoTargets.listIterator(); iterator.hasNext(); ) {
545            final var jid = iterator.next();
546            if (!members.contains(jid) && !members.contains(jid.getDomain())) {
547                iterator.remove();
548                Log.d(
549                        Config.LOGTAG,
550                        getAccount().getJid().asBareJid()
551                                + ": removed "
552                                + jid
553                                + " from crypto targets of "
554                                + conversation.getName());
555                changed = true;
556            }
557        }
558        if (changed) {
559            conversation.setAcceptedCryptoTargets(cryptoTargets);
560            getDatabase().updateConversation(conversation);
561        }
562        // TODO only when room has no avatar
563        this.service.getAvatarService().clear(mucOptions);
564        this.service.updateMucRosterUi();
565        this.service.updateConversationUi();
566    }
567
568    private ListenableFuture<Collection<MucOptions.User>> fetchAffiliations(
569            final Conversation conversation, final Affiliation affiliation) {
570        final var iq = new Iq(Iq.Type.GET);
571        iq.setTo(conversation.getJid().asBareJid());
572        iq.addExtension(new MucAdmin()).addExtension(new Item()).setAffiliation(affiliation);
573        return Futures.transform(
574                this.connection.sendIqPacket(iq),
575                response -> {
576                    final var mucAdmin = response.getExtension(MucAdmin.class);
577                    if (mucAdmin == null) {
578                        throw new IllegalStateException("No query in response");
579                    }
580                    return Collections2.transform(
581                            mucAdmin.getItems(), i -> itemToUser(conversation, i, null));
582                },
583                MoreExecutors.directExecutor());
584    }
585
586    public ListenableFuture<Void> changeUsername(
587            final Conversation conversation, final String username) {
588
589        // TODO when online send normal available presence
590        // TODO when not online do a normal join
591
592        final Bookmark bookmark = conversation.getBookmark();
593        final MucOptions options = conversation.getMucOptions();
594        final Jid joinJid = options.createJoinJid(username);
595        if (joinJid == null) {
596            return Futures.immediateFailedFuture(new IllegalArgumentException());
597        }
598
599        if (options.online()) {
600            final SettableFuture<Void> renameFuture = SettableFuture.create();
601            options.setOnRenameListener(
602                    new MucOptions.OnRenameListener() {
603
604                        @Override
605                        public void onSuccess() {
606                            renameFuture.set(null);
607                        }
608
609                        @Override
610                        public void onFailure() {
611                            renameFuture.setException(new IllegalStateException());
612                        }
613                    });
614
615            available(joinJid, options.nonanonymous());
616
617            if (username.equals(MucOptions.defaultNick(getAccount()))
618                    && bookmark != null
619                    && bookmark.getNick() != null) {
620                Log.d(
621                        Config.LOGTAG,
622                        getAccount().getJid().asBareJid()
623                                + ": removing nick from bookmark for "
624                                + bookmark.getJid());
625                bookmark.setNick(null);
626                getManager(BookmarkManager.class).create(bookmark);
627            }
628            return renameFuture;
629        } else {
630            conversation.setContactJid(joinJid);
631            getDatabase().updateConversation(conversation);
632            if (bookmark != null) {
633                bookmark.setNick(username);
634                getManager(BookmarkManager.class).create(bookmark);
635            }
636            join(conversation);
637            return Futures.immediateVoidFuture();
638        }
639    }
640
641    public void checkMucRequiresRename(final Conversation conversation) {
642        final var options = conversation.getMucOptions();
643        if (!options.online()) {
644            return;
645        }
646        final String current = options.getActualNick();
647        final String proposed = options.getProposedNickPure();
648        if (current == null || current.equals(proposed)) {
649            return;
650        }
651        final Jid joinJid = options.createJoinJid(proposed);
652        Log.d(
653                Config.LOGTAG,
654                String.format(
655                        "%s: muc rename required %s (was: %s)",
656                        getAccount().getJid().asBareJid(), joinJid, current));
657        available(joinJid, options.nonanonymous());
658    }
659
660    public void setPassword(final Conversation conversation, final String password) {
661        final var bookmark = conversation.getBookmark();
662        conversation.getMucOptions().setPassword(password);
663        if (bookmark != null) {
664            bookmark.setAutojoin(true);
665            getManager(BookmarkManager.class).create(bookmark);
666        }
667        getDatabase().updateConversation(conversation);
668        this.join(conversation);
669    }
670
671    public void pingAndRejoin(final Conversation conversation) {
672        final Account account = getAccount();
673        synchronized (this.inProgressConferenceJoins) {
674            if (this.inProgressConferenceJoins.contains(conversation)) {
675                Log.d(
676                        Config.LOGTAG,
677                        account.getJid().asBareJid()
678                                + ": canceling muc self ping because join is already under way");
679                return;
680            }
681        }
682        synchronized (this.inProgressConferencePings) {
683            if (!this.inProgressConferencePings.add(conversation)) {
684                Log.d(
685                        Config.LOGTAG,
686                        account.getJid().asBareJid()
687                                + ": canceling muc self ping because ping is already under way");
688                return;
689            }
690        }
691        final Jid self = conversation.getMucOptions().getSelf().getFullJid();
692        final var future = getManager(PingManager.class).ping(self);
693        Futures.addCallback(
694                future,
695                new FutureCallback<>() {
696                    @Override
697                    public void onSuccess(Iq result) {
698                        Log.d(
699                                Config.LOGTAG,
700                                account.getJid().asBareJid()
701                                        + ": ping to "
702                                        + self
703                                        + " came back fine");
704                        synchronized (MultiUserChatManager.this.inProgressConferencePings) {
705                            MultiUserChatManager.this.inProgressConferencePings.remove(
706                                    conversation);
707                        }
708                    }
709
710                    @Override
711                    public void onFailure(@NonNull Throwable throwable) {
712                        synchronized (MultiUserChatManager.this.inProgressConferencePings) {
713                            MultiUserChatManager.this.inProgressConferencePings.remove(
714                                    conversation);
715                        }
716                        if (throwable instanceof IqErrorException iqErrorException) {
717                            final var condition = iqErrorException.getErrorCondition();
718                            if (condition instanceof Condition.ServiceUnavailable
719                                    || condition instanceof Condition.FeatureNotImplemented
720                                    || condition instanceof Condition.ItemNotFound) {
721                                Log.d(
722                                        Config.LOGTAG,
723                                        account.getJid().asBareJid()
724                                                + ": ping to "
725                                                + self
726                                                + " came back as ignorable error");
727                            } else {
728                                Log.d(
729                                        Config.LOGTAG,
730                                        account.getJid().asBareJid()
731                                                + ": ping to "
732                                                + self
733                                                + " failed. attempting rejoin");
734                                join(conversation);
735                            }
736                        }
737                    }
738                },
739                MoreExecutors.directExecutor());
740    }
741
742    public ListenableFuture<Void> destroy(final Jid address) {
743        final var iq = new Iq(Iq.Type.SET);
744        iq.setTo(address);
745        final var mucOwner = iq.addExtension(new MucOwner());
746        mucOwner.addExtension(new Destroy());
747        return Futures.transform(
748                connection.sendIqPacket(iq), result -> null, MoreExecutors.directExecutor());
749    }
750
751    public ListenableFuture<Void> setAffiliation(
752            final Conversation conversation, final Affiliation affiliation, Jid user) {
753        return setAffiliation(conversation, affiliation, Collections.singleton(user));
754    }
755
756    public ListenableFuture<Void> setAffiliation(
757            final Conversation conversation,
758            final Affiliation affiliation,
759            final Collection<Jid> users) {
760        final var address = conversation.getJid().asBareJid();
761        final var iq = new Iq(Iq.Type.SET);
762        iq.setTo(address);
763        final var admin = iq.addExtension(new MucAdmin());
764        for (final var user : users) {
765            final var item = admin.addExtension(new Item());
766            item.setJid(user);
767            item.setAffiliation(affiliation);
768        }
769        return Futures.transform(
770                this.connection.sendIqPacket(iq),
771                response -> {
772                    // TODO figure out what this was meant to do
773                    // is this a work around for some servers not sending notifications when
774                    // changing the affiliation of people not in the room? this would explain this
775                    // firing only when getRole == None
776                    final var mucOptions = conversation.getMucOptions();
777                    for (final var user : users) {
778                        mucOptions.changeAffiliation(user, affiliation);
779                    }
780                    service.getAvatarService().clear(mucOptions);
781                    return null;
782                },
783                MoreExecutors.directExecutor());
784    }
785
786    public ListenableFuture<Void> setRole(final Jid address, final Role role, final String user) {
787        return setRole(address, role, Collections.singleton(user));
788    }
789
790    public ListenableFuture<Void> setRole(
791            final Jid address, final Role role, final Collection<String> users) {
792        final var iq = new Iq(Iq.Type.SET);
793        iq.setTo(address);
794        final var admin = iq.addExtension(new MucAdmin());
795        for (final var user : users) {
796            final var item = admin.addExtension(new Item());
797            item.setNick(user);
798            item.setRole(role);
799        }
800        return Futures.transform(
801                this.connection.sendIqPacket(iq), response -> null, MoreExecutors.directExecutor());
802    }
803
804    public void setSubject(final Conversation conversation, final String subject) {
805        final var message = new Message();
806        message.setType(Message.Type.GROUPCHAT);
807        message.setTo(conversation.getJid().asBareJid());
808        message.addExtension(new Subject(subject));
809        connection.sendMessagePacket(message);
810    }
811
812    public void invite(final Conversation conversation, final Jid address) {
813        Log.d(
814                Config.LOGTAG,
815                conversation.getAccount().getJid().asBareJid()
816                        + ": inviting "
817                        + address
818                        + " to "
819                        + conversation.getJid().asBareJid());
820        final MucOptions.User user =
821                conversation.getMucOptions().findUserByRealJid(address.asBareJid());
822        if (user == null || user.getAffiliation() == Affiliation.OUTCAST) {
823            this.setAffiliation(conversation, Affiliation.NONE, address);
824        }
825
826        final var packet = new Message();
827        packet.setTo(conversation.getJid().asBareJid());
828        final var x = packet.addExtension(new MucUser());
829        final var invite = x.addExtension(new Invite());
830        invite.setTo(address.asBareJid());
831        connection.sendMessagePacket(packet);
832    }
833
834    public void directInvite(final Conversation conversation, final Jid address) {
835        final var message = new Message();
836        message.setTo(address);
837        final var directInvite = message.addExtension(new DirectInvite());
838        directInvite.setJid(conversation.getJid().asBareJid());
839        final var password = conversation.getMucOptions().getPassword();
840        if (password != null) {
841            directInvite.setPassword(password);
842        }
843        if (address.isFullJid()) {
844            message.addExtension(new NoStore());
845            message.addExtension(new NoCopy());
846        }
847        this.connection.sendMessagePacket(message);
848    }
849
850    public boolean isJoinInProgress(final Conversation conversation) {
851        synchronized (this.inProgressConferenceJoins) {
852            if (conversation.getMode() == Conversational.MODE_MULTI) {
853                final boolean inProgress = this.inProgressConferenceJoins.contains(conversation);
854                if (inProgress) {
855                    Log.d(
856                            Config.LOGTAG,
857                            getAccount().getJid().asBareJid()
858                                    + ": holding back message to group. join in progress");
859                }
860                return inProgress;
861            } else {
862                return false;
863            }
864        }
865    }
866
867    public void clearInProgress() {
868        synchronized (this.inProgressConferenceJoins) {
869            this.inProgressConferenceJoins.clear();
870        }
871        synchronized (this.inProgressConferencePings) {
872            this.inProgressConferencePings.clear();
873        }
874    }
875
876    public Jid getService() {
877        return Iterables.getFirst(this.getServices(), null);
878    }
879
880    public List<Jid> getServices() {
881        final var builder = new ImmutableList.Builder<Jid>();
882        for (final var entry : getManager(DiscoManager.class).getServerItems().entrySet()) {
883            final var value = entry.getValue();
884            if (value.getFeatureStrings().contains(Namespace.MUC)
885                    && value.hasIdentityWithCategoryAndType("conference", "text")
886                    && !value.getFeatureStrings().contains("jabber:iq:gateway")
887                    && !value.hasIdentityWithCategoryAndType("conference", "irc")) {
888                builder.add(entry.getKey());
889            }
890        }
891        return builder.build();
892    }
893
894    public static MucOptions.User itemToUser(
895            final Conversation conference,
896            im.conversations.android.xmpp.model.muc.Item item,
897            final Jid from) {
898        final var affiliation = item.getAffiliation();
899        final var role = item.getRole();
900        final var nick = item.getNick();
901        final Jid fullAddress;
902        if (from != null && from.isFullJid()) {
903            fullAddress = from;
904        } else if (Strings.isNullOrEmpty(nick)) {
905            fullAddress = null;
906        } else {
907            fullAddress = ofNick(conference, nick);
908        }
909        final Jid realJid = item.getAttributeAsJid("jid");
910        MucOptions.User user = new MucOptions.User(conference.getMucOptions(), fullAddress);
911        if (Jid.Invalid.isValid(realJid)) {
912            user.setRealJid(realJid);
913        }
914        user.setAffiliation(affiliation);
915        user.setRole(role);
916        return user;
917    }
918
919    private static Jid ofNick(final Conversation conversation, final String nick) {
920        try {
921            return conversation.getJid().withResource(nick);
922        } catch (final IllegalArgumentException e) {
923            return null;
924        }
925    }
926
927    private static Map<String, Object> modifyBestInteroperability(
928            final Map<String, Object> unmodified) {
929        final var builder = new ImmutableMap.Builder<String, Object>();
930        builder.putAll(unmodified);
931
932        if (unmodified.get("muc#roomconfig_moderatedroom") instanceof Boolean moderated) {
933            builder.put("members_by_default", !moderated);
934        }
935        if (unmodified.get("muc#roomconfig_allowpm") instanceof String allowPm) {
936            // ejabberd :-/
937            final boolean allow = "anyone".equals(allowPm);
938            builder.put("allow_private_messages", allow);
939            builder.put("allow_private_messages_from_visitors", allow ? "anyone" : "nobody");
940        }
941
942        if (unmodified.get("muc#roomconfig_allowinvites") instanceof Boolean allowInvites) {
943            // TODO check that this actually does something useful?
944            builder.put(
945                    "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", allowInvites);
946        }
947
948        return builder.buildOrThrow();
949    }
950
951    private static Map<String, Object> configWithName(
952            final Map<String, Object> unmodified, final String name) {
953        if (Strings.isNullOrEmpty(name)) {
954            return unmodified;
955        }
956        return new ImmutableMap.Builder<String, Object>()
957                .putAll(unmodified)
958                .put("muc#roomconfig_roomname", name)
959                .buildKeepingLast();
960    }
961
962    public static Map<String, Object> defaultGroupChatConfiguration() {
963        return new ImmutableMap.Builder<String, Object>()
964                .put("muc#roomconfig_persistentroom", true)
965                .put("muc#roomconfig_membersonly", true)
966                .put("muc#roomconfig_publicroom", false)
967                .put("muc#roomconfig_whois", "anyone")
968                .put("muc#roomconfig_changesubject", false)
969                .put("muc#roomconfig_allowinvites", false)
970                .put("muc#roomconfig_enablearchiving", true) // prosody
971                .put("mam", true) // ejabberd community
972                .put("muc#roomconfig_mam", true) // ejabberd saas
973                .buildOrThrow();
974    }
975
976    public static Map<String, Object> defaultChannelConfiguration() {
977        return new ImmutableMap.Builder<String, Object>()
978                .put("muc#roomconfig_persistentroom", true)
979                .put("muc#roomconfig_membersonly", false)
980                .put("muc#roomconfig_publicroom", true)
981                .put("muc#roomconfig_whois", "moderators")
982                .put("muc#roomconfig_changesubject", false)
983                .put("muc#roomconfig_enablearchiving", true) // prosody
984                .put("mam", true) // ejabberd community
985                .put("muc#roomconfig_mam", true) // ejabberd saas
986                .buildOrThrow();
987    }
988}