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