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().setAutoPushConfiguration(autoPushConfiguration);
  91        conversation.setHasMessagesLeftOnServer(false);
  92        final var disco = fetchDiscoInfo(conversation);
  93
  94        final var caughtDisco =
  95                Futures.catchingAsync(
  96                        disco,
  97                        IqErrorException.class,
  98                        ex -> {
  99                            if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
 100                                return Futures.immediateFailedFuture(
 101                                        new IllegalStateException(
 102                                                "conversation got archived before disco returned"));
 103                            }
 104                            Log.d(Config.LOGTAG, "error fetching disco#info", ex);
 105                            final var iqError = ex.getError();
 106                            if (iqError != null
 107                                    && iqError.getCondition()
 108                                            instanceof Condition.RemoteServerNotFound) {
 109                                synchronized (this.inProgressConferenceJoins) {
 110                                    this.inProgressConferenceJoins.remove(conversation);
 111                                }
 112                                conversation
 113                                        .getMucOptions()
 114                                        .setError(MucOptions.Error.SERVER_NOT_FOUND);
 115                                service.updateConversationUi();
 116                                return Futures.immediateFailedFuture(ex);
 117                            } else {
 118                                return Futures.immediateFuture(new InfoQuery());
 119                            }
 120                        },
 121                        MoreExecutors.directExecutor());
 122
 123        return Futures.transform(
 124                caughtDisco,
 125                v -> {
 126                    checkConfigurationSendPresenceFetchHistory(conversation);
 127                    return null;
 128                },
 129                MoreExecutors.directExecutor());
 130    }
 131
 132    public ListenableFuture<Void> joinFollowingInvite(final Conversation conversation) {
 133        // TODO this special treatment is probably unnecessary; just always make sure the bookmark
 134        // exists
 135        return Futures.transform(
 136                join(conversation),
 137                v -> {
 138                    // we used to do this only for private groups
 139                    final Bookmark bookmark = conversation.getBookmark();
 140                    if (bookmark != null) {
 141                        if (bookmark.autojoin()) {
 142                            return null;
 143                        }
 144                        bookmark.setAutojoin(true);
 145                        getManager(BookmarkManager.class).create(bookmark);
 146                    } else {
 147                        getManager(BookmarkManager.class).save(conversation, null);
 148                    }
 149                    return null;
 150                },
 151                MoreExecutors.directExecutor());
 152    }
 153
 154    private void checkConfigurationSendPresenceFetchHistory(final Conversation conversation) {
 155
 156        Account account = conversation.getAccount();
 157        final MucOptions mucOptions = conversation.getMucOptions();
 158
 159        if (mucOptions.nonanonymous()
 160                && !mucOptions.membersOnly()
 161                && !conversation.getBooleanAttribute("accept_non_anonymous", false)) {
 162            synchronized (this.inProgressConferenceJoins) {
 163                this.inProgressConferenceJoins.remove(conversation);
 164            }
 165            mucOptions.setError(MucOptions.Error.NON_ANONYMOUS);
 166            service.updateConversationUi();
 167            return;
 168        }
 169
 170        final Jid joinJid = mucOptions.getSelf().getFullJid();
 171        Log.d(
 172                Config.LOGTAG,
 173                account.getJid().asBareJid().toString()
 174                        + ": joining conversation "
 175                        + joinJid.toString());
 176
 177        final var x = new MultiUserChat();
 178
 179        if (mucOptions.getPassword() != null) {
 180            x.addExtension(new Password(mucOptions.getPassword()));
 181        }
 182
 183        final var history = x.addExtension(new History());
 184
 185        if (mucOptions.mamSupport()) {
 186            // Use MAM instead of the limited muc history to get history
 187            history.setMaxStanzas(0);
 188        } else {
 189            // Fallback to muc history
 190            history.setSince(conversation.getLastMessageTransmitted().getTimestamp());
 191        }
 192        available(joinJid, mucOptions.nonanonymous(), x);
 193        if (!joinJid.equals(conversation.getJid())) {
 194            conversation.setContactJid(joinJid);
 195            getDatabase().updateConversation(conversation);
 196        }
 197
 198        if (mucOptions.mamSupport()) {
 199            this.service.getMessageArchiveService().catchupMUC(conversation);
 200        }
 201        fetchMembers(conversation);
 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, null, null, 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        var avatarHash =
 394                result.getServiceDiscoveryExtension(
 395                        Namespace.MUC_ROOM_INFO, "muc#roominfo_avatarhash");
 396        if (avatarHash == null) {
 397            avatarHash = result.getServiceDiscoveryExtension(Namespace.MUC_ROOM_INFO, "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1");
 398        }
 399        if (VCardUpdate.isValidSHA1(avatarHash)) {
 400            connection.getManager(AvatarManager.class).handleVCardUpdate(address, avatarHash);
 401        }
 402        final MucOptions mucOptions = conversation.getMucOptions();
 403        final Bookmark bookmark = conversation.getBookmark();
 404        final boolean sameBefore =
 405                StringUtils.equals(
 406                        bookmark == null ? null : bookmark.getBookmarkName(), mucOptions.getName());
 407
 408        final var hadOccupantId = mucOptions.occupantId();
 409        if (mucOptions.updateConfiguration(result)) {
 410            Log.d(
 411                    Config.LOGTAG,
 412                    account.getJid().asBareJid()
 413                            + ": muc configuration changed for "
 414                            + conversation.getJid().asBareJid());
 415            getDatabase().updateConversation(conversation);
 416        }
 417
 418        final var hasOccupantId = mucOptions.occupantId();
 419
 420        if (!hadOccupantId && hasOccupantId && mucOptions.online()) {
 421            final var me = mucOptions.getSelf().getFullJid();
 422            Log.d(
 423                    Config.LOGTAG,
 424                    account.getJid().asBareJid()
 425                            + ": gained support for occupant-id in "
 426                            + me
 427                            + ". resending presence");
 428            this.available(me, mucOptions.nonanonymous());
 429        }
 430
 431        if (bookmark != null && (sameBefore || bookmark.getBookmarkName() == null)) {
 432            if (bookmark.setBookmarkName(StringUtils.nullOnEmpty(mucOptions.getName()))) {
 433                getManager(BookmarkManager.class).create(bookmark);
 434            }
 435        }
 436        this.service.updateConversationUi();
 437    }
 438
 439    public void resendPresence(final Conversation conversation) {
 440        final MucOptions mucOptions = conversation.getMucOptions();
 441        if (mucOptions.online()) {
 442            available(mucOptions.getSelf().getFullJid(), mucOptions.nonanonymous());
 443        }
 444    }
 445
 446    private void available(
 447            final Jid address, final boolean nonAnonymous, final Extension... extensions) {
 448        final var presenceManager = getManager(PresenceManager.class);
 449        final var account = getAccount();
 450        final String pgpSignature = account.getPgpSignature();
 451        if (nonAnonymous && pgpSignature != null) {
 452            final String message = account.getPresenceStatusMessage();
 453            presenceManager.available(
 454                    address, message, combine(extensions, new Signed(pgpSignature)));
 455        } else {
 456            presenceManager.available(address, extensions);
 457        }
 458    }
 459
 460    public void unavailable(final Conversation conversation) {
 461        final var mucOptions = conversation.getMucOptions();
 462        getManager(PresenceManager.class).unavailable(mucOptions.getSelf().getFullJid());
 463    }
 464
 465    private static Extension[] combine(final Extension[] extensions, final Extension extension) {
 466        return new ImmutableList.Builder<Extension>()
 467                .addAll(Arrays.asList(extensions))
 468                .add(extension)
 469                .build()
 470                .toArray(new Extension[0]);
 471    }
 472
 473    public ListenableFuture<Void> pushConfiguration(
 474            final Conversation conversation, final Map<String, Object> input) {
 475        final var address = conversation.getJid().asBareJid();
 476        final var configuration = modifyBestInteroperability(input);
 477
 478        if (configuration.get("muc#roomconfig_whois") instanceof String whois
 479                && whois.equals("anyone")) {
 480            conversation.setAttribute("accept_non_anonymous", true);
 481            getDatabase().updateConversation(conversation);
 482        }
 483
 484        final var future = fetchConfigurationForm(address);
 485        return Futures.transformAsync(
 486                future,
 487                current -> {
 488                    final var modified = current.submit(configuration);
 489                    return submitConfigurationForm(address, modified);
 490                },
 491                MoreExecutors.directExecutor());
 492    }
 493
 494    public ListenableFuture<Data> fetchConfigurationForm(final Jid address) {
 495        final var iq = new Iq(Iq.Type.GET, new MucOwner());
 496        iq.setTo(address);
 497        Log.d(Config.LOGTAG, "fetching configuration form: " + iq);
 498        return Futures.transform(
 499                connection.sendIqPacket(iq),
 500                response -> {
 501                    final var mucOwner = response.getExtension(MucOwner.class);
 502                    if (mucOwner == null) {
 503                        throw new IllegalStateException("Missing MucOwner element in response");
 504                    }
 505                    return mucOwner.getConfiguration();
 506                },
 507                MoreExecutors.directExecutor());
 508    }
 509
 510    private ListenableFuture<Void> submitConfigurationForm(final Jid address, final Data data) {
 511        final var iq = new Iq(Iq.Type.SET);
 512        iq.setTo(address);
 513        final var mucOwner = iq.addExtension(new MucOwner());
 514        mucOwner.addExtension(data);
 515        Log.d(Config.LOGTAG, "pushing configuration form: " + iq);
 516        return Futures.transform(
 517                this.connection.sendIqPacket(iq), response -> null, MoreExecutors.directExecutor());
 518    }
 519
 520    public ListenableFuture<Void> fetchMembers(final Conversation conversation) {
 521        final var affiliations = new ArrayList<Affiliation>();
 522        affiliations.add(Affiliation.OUTCAST);
 523         if (conversation.getMucOptions().isPrivateAndNonAnonymous()) affiliations.addAll(List.of(Affiliation.OWNER, Affiliation.ADMIN, Affiliation.MEMBER));
 524        final var futures =
 525                Collections2.transform(
 526                        affiliations,
 527                        a -> fetchAffiliations(conversation, a));
 528        ListenableFuture<List<MucOptions.User>> future = FutureMerger.allAsList(futures);
 529        return Futures.transform(
 530                future,
 531                members -> {
 532                    setMembers(conversation, members);
 533                    return null;
 534                },
 535                MoreExecutors.directExecutor());
 536    }
 537
 538    private void setMembers(final Conversation conversation, final List<MucOptions.User> users) {
 539        for (final var user : users) {
 540            if (user.realJidMatchesAccount()) {
 541                continue;
 542            }
 543            boolean isNew = conversation.getMucOptions().updateUser(user);
 544            fetchDeviceIdsIfNeeded(isNew, user);
 545        }
 546        final var mucOptions = conversation.getMucOptions();
 547        final var members = mucOptions.getMembers(true);
 548        final var cryptoTargets = conversation.getAcceptedCryptoTargets();
 549        boolean changed = false;
 550        for (final var iterator = cryptoTargets.listIterator(); iterator.hasNext(); ) {
 551            final var jid = iterator.next();
 552            if (!members.contains(jid) && !members.contains(jid.getDomain())) {
 553                iterator.remove();
 554                Log.d(
 555                        Config.LOGTAG,
 556                        getAccount().getJid().asBareJid()
 557                                + ": removed "
 558                                + jid
 559                                + " from crypto targets of "
 560                                + conversation.getName());
 561                changed = true;
 562            }
 563        }
 564        if (changed) {
 565            conversation.setAcceptedCryptoTargets(cryptoTargets);
 566            getDatabase().updateConversation(conversation);
 567        }
 568        // TODO only when room has no avatar
 569        this.service.getAvatarService().clear(mucOptions);
 570        this.service.updateMucRosterUi();
 571        this.service.updateConversationUi();
 572    }
 573
 574    private ListenableFuture<Collection<MucOptions.User>> fetchAffiliations(
 575            final Conversation conversation, final Affiliation affiliation) {
 576        final var iq = new Iq(Iq.Type.GET);
 577        iq.setTo(conversation.getJid().asBareJid());
 578        iq.addExtension(new MucAdmin()).addExtension(new Item()).setAffiliation(affiliation);
 579        return Futures.transform(
 580                this.connection.sendIqPacket(iq),
 581                response -> {
 582                    final var mucAdmin = response.getExtension(MucAdmin.class);
 583                    if (mucAdmin == null) {
 584                        throw new IllegalStateException("No query in response");
 585                    }
 586                    return Collections2.transform(
 587                            mucAdmin.getItems(), i -> itemToUser(conversation, i, null, null, null, null));
 588                },
 589                MoreExecutors.directExecutor());
 590    }
 591
 592    public ListenableFuture<Void> changeUsername(
 593            final Conversation conversation, final String username) {
 594
 595        // TODO when online send normal available presence
 596        // TODO when not online do a normal join
 597
 598        final Bookmark bookmark = conversation.getBookmark();
 599        final MucOptions options = conversation.getMucOptions();
 600        final Jid joinJid = options.createJoinJid(username);
 601        if (joinJid == null) {
 602            return Futures.immediateFailedFuture(new IllegalArgumentException());
 603        }
 604
 605        if (options.online()) {
 606            final SettableFuture<Void> renameFuture = SettableFuture.create();
 607            options.setOnRenameListener(
 608                    new MucOptions.OnRenameListener() {
 609
 610                        @Override
 611                        public void onSuccess() {
 612                            renameFuture.set(null);
 613                        }
 614
 615                        @Override
 616                        public void onFailure() {
 617                            renameFuture.setException(new IllegalStateException());
 618                        }
 619                    });
 620
 621            available(joinJid, options.nonanonymous());
 622
 623            if (username.equals(MucOptions.defaultNick(getAccount()))
 624                    && bookmark != null
 625                    && bookmark.getNick() != null) {
 626                Log.d(
 627                        Config.LOGTAG,
 628                        getAccount().getJid().asBareJid()
 629                                + ": removing nick from bookmark for "
 630                                + bookmark.getJid());
 631                bookmark.setNick(null);
 632                getManager(BookmarkManager.class).create(bookmark);
 633            }
 634            return renameFuture;
 635        } else {
 636            conversation.setContactJid(joinJid);
 637            getDatabase().updateConversation(conversation);
 638            if (bookmark != null) {
 639                bookmark.setNick(username);
 640                getManager(BookmarkManager.class).create(bookmark);
 641            }
 642            join(conversation);
 643            return Futures.immediateVoidFuture();
 644        }
 645    }
 646
 647    public void checkMucRequiresRename(final Conversation conversation) {
 648        final var options = conversation.getMucOptions();
 649        if (!options.online()) {
 650            return;
 651        }
 652        final String current = options.getActualNick();
 653        final String proposed = options.getProposedNickPure();
 654        if (current == null || current.equals(proposed)) {
 655            return;
 656        }
 657        final Jid joinJid = options.createJoinJid(proposed);
 658        Log.d(
 659                Config.LOGTAG,
 660                String.format(
 661                        "%s: muc rename required %s (was: %s)",
 662                        getAccount().getJid().asBareJid(), joinJid, current));
 663        available(joinJid, options.nonanonymous());
 664    }
 665
 666    public void setPassword(final Conversation conversation, final String password) {
 667        final var bookmark = conversation.getBookmark();
 668        conversation.getMucOptions().setPassword(password);
 669        if (bookmark != null) {
 670            bookmark.setAutojoin(true);
 671            getManager(BookmarkManager.class).create(bookmark);
 672        }
 673        getDatabase().updateConversation(conversation);
 674        this.join(conversation);
 675    }
 676
 677    public void pingAndRejoin(final Conversation conversation) {
 678        final Account account = getAccount();
 679        synchronized (this.inProgressConferenceJoins) {
 680            if (this.inProgressConferenceJoins.contains(conversation)) {
 681                Log.d(
 682                        Config.LOGTAG,
 683                        account.getJid().asBareJid()
 684                                + ": canceling muc self ping because join is already under way");
 685                return;
 686            }
 687        }
 688        synchronized (this.inProgressConferencePings) {
 689            if (!this.inProgressConferencePings.add(conversation)) {
 690                Log.d(
 691                        Config.LOGTAG,
 692                        account.getJid().asBareJid()
 693                                + ": canceling muc self ping because ping is already under way");
 694                return;
 695            }
 696        }
 697        final Jid self = conversation.getMucOptions().getSelf().getFullJid();
 698        final var future = getManager(PingManager.class).ping(self);
 699        Futures.addCallback(
 700                future,
 701                new FutureCallback<>() {
 702                    @Override
 703                    public void onSuccess(Iq result) {
 704                        Log.d(
 705                                Config.LOGTAG,
 706                                account.getJid().asBareJid()
 707                                        + ": ping to "
 708                                        + self
 709                                        + " came back fine");
 710                        synchronized (MultiUserChatManager.this.inProgressConferencePings) {
 711                            MultiUserChatManager.this.inProgressConferencePings.remove(
 712                                    conversation);
 713                        }
 714                    }
 715
 716                    @Override
 717                    public void onFailure(@NonNull Throwable throwable) {
 718                        synchronized (MultiUserChatManager.this.inProgressConferencePings) {
 719                            MultiUserChatManager.this.inProgressConferencePings.remove(
 720                                    conversation);
 721                        }
 722                        if (throwable instanceof IqErrorException iqErrorException) {
 723                            final var condition = iqErrorException.getErrorCondition();
 724                            if (condition instanceof Condition.ServiceUnavailable
 725                                    || condition instanceof Condition.FeatureNotImplemented
 726                                    || condition instanceof Condition.ItemNotFound) {
 727                                Log.d(
 728                                        Config.LOGTAG,
 729                                        account.getJid().asBareJid()
 730                                                + ": ping to "
 731                                                + self
 732                                                + " came back as ignorable error");
 733                            } else {
 734                                Log.d(
 735                                        Config.LOGTAG,
 736                                        account.getJid().asBareJid()
 737                                                + ": ping to "
 738                                                + self
 739                                                + " failed. attempting rejoin");
 740                                join(conversation);
 741                            }
 742                        }
 743                    }
 744                },
 745                MoreExecutors.directExecutor());
 746    }
 747
 748    public ListenableFuture<Void> destroy(final Jid address) {
 749        final var iq = new Iq(Iq.Type.SET);
 750        iq.setTo(address);
 751        final var mucOwner = iq.addExtension(new MucOwner());
 752        mucOwner.addExtension(new Destroy());
 753        return Futures.transform(
 754                connection.sendIqPacket(iq), result -> null, MoreExecutors.directExecutor());
 755    }
 756
 757    public ListenableFuture<Void> setAffiliation(
 758            final Conversation conversation, final Affiliation affiliation, Jid user) {
 759        return setAffiliation(conversation, affiliation, Collections.singleton(user));
 760    }
 761
 762    public ListenableFuture<Void> setAffiliation(
 763            final Conversation conversation,
 764            final Affiliation affiliation,
 765            final Collection<Jid> users) {
 766        final var address = conversation.getJid().asBareJid();
 767        final var iq = new Iq(Iq.Type.SET);
 768        iq.setTo(address);
 769        final var admin = iq.addExtension(new MucAdmin());
 770        for (final var user : users) {
 771            final var item = admin.addExtension(new Item());
 772            item.setJid(user);
 773            item.setAffiliation(affiliation);
 774        }
 775        return Futures.transform(
 776                this.connection.sendIqPacket(iq),
 777                response -> {
 778                    // TODO figure out what this was meant to do
 779                    // is this a work around for some servers not sending notifications when
 780                    // changing the affiliation of people not in the room? this would explain this
 781                    // firing only when getRole == None
 782                    final var mucOptions = conversation.getMucOptions();
 783                    for (final var user : users) {
 784                        mucOptions.changeAffiliation(user, affiliation);
 785                    }
 786                    service.getAvatarService().clear(mucOptions);
 787                    return null;
 788                },
 789                MoreExecutors.directExecutor());
 790    }
 791
 792    public ListenableFuture<Void> setRole(final Jid address, final Role role, final String user) {
 793        return setRole(address, role, Collections.singleton(user));
 794    }
 795
 796    public ListenableFuture<Void> setRole(
 797            final Jid address, final Role role, final Collection<String> users) {
 798        final var iq = new Iq(Iq.Type.SET);
 799        iq.setTo(address);
 800        final var admin = iq.addExtension(new MucAdmin());
 801        for (final var user : users) {
 802            final var item = admin.addExtension(new Item());
 803            item.setNick(user);
 804            item.setRole(role);
 805        }
 806        return Futures.transform(
 807                this.connection.sendIqPacket(iq), response -> null, MoreExecutors.directExecutor());
 808    }
 809
 810    public void setSubject(final Conversation conversation, final String subject) {
 811        final var message = new Message();
 812        message.setType(Message.Type.GROUPCHAT);
 813        message.setTo(conversation.getJid().asBareJid());
 814        message.addExtension(new Subject(subject));
 815        connection.sendMessagePacket(message);
 816    }
 817
 818    public void invite(final Conversation conversation, final Jid address) {
 819        Log.d(
 820                Config.LOGTAG,
 821                conversation.getAccount().getJid().asBareJid()
 822                        + ": inviting "
 823                        + address
 824                        + " to "
 825                        + conversation.getJid().asBareJid());
 826        final MucOptions.User user =
 827                conversation.getMucOptions().findUserByRealJid(address.asBareJid());
 828        if (user == null || user.getAffiliation() == Affiliation.OUTCAST) {
 829            this.setAffiliation(conversation, Affiliation.NONE, address);
 830        }
 831
 832        final var packet = new Message();
 833        packet.setTo(conversation.getJid().asBareJid());
 834        final var x = packet.addExtension(new MucUser());
 835        final var invite = x.addExtension(new Invite());
 836        invite.setTo(address.asBareJid());
 837        connection.sendMessagePacket(packet);
 838    }
 839
 840    public void directInvite(final Conversation conversation, final Jid address) {
 841        final var message = new Message();
 842        message.setTo(address);
 843        final var directInvite = message.addExtension(new DirectInvite());
 844        directInvite.setJid(conversation.getJid().asBareJid());
 845        final var password = conversation.getMucOptions().getPassword();
 846        if (password != null) {
 847            directInvite.setPassword(password);
 848        }
 849        if (address.isFullJid()) {
 850            message.addExtension(new NoStore());
 851            message.addExtension(new NoCopy());
 852        }
 853        this.connection.sendMessagePacket(message);
 854    }
 855
 856    public boolean isJoinInProgress(final Conversation conversation) {
 857        synchronized (this.inProgressConferenceJoins) {
 858            if (conversation.getMode() == Conversational.MODE_MULTI) {
 859                final boolean inProgress = this.inProgressConferenceJoins.contains(conversation);
 860                if (inProgress) {
 861                    Log.d(
 862                            Config.LOGTAG,
 863                            getAccount().getJid().asBareJid()
 864                                    + ": holding back message to group. join in progress");
 865                }
 866                return inProgress;
 867            } else {
 868                return false;
 869            }
 870        }
 871    }
 872
 873    public void clearInProgress() {
 874        synchronized (this.inProgressConferenceJoins) {
 875            this.inProgressConferenceJoins.clear();
 876        }
 877        synchronized (this.inProgressConferencePings) {
 878            this.inProgressConferencePings.clear();
 879        }
 880    }
 881
 882    public Jid getService() {
 883        return Iterables.getFirst(this.getServices(), null);
 884    }
 885
 886    public List<Jid> getServices() {
 887        final var builder = new ImmutableList.Builder<Jid>();
 888        for (final var entry : getManager(DiscoManager.class).getServerItems().entrySet()) {
 889            final var value = entry.getValue();
 890            if (value.getFeatureStrings().contains(Namespace.MUC)
 891                    && value.hasIdentityWithCategoryAndType("conference", "text")
 892                    && !value.getFeatureStrings().contains("jabber:iq:gateway")
 893                    && !value.hasIdentityWithCategoryAndType("conference", "irc")) {
 894                builder.add(entry.getKey());
 895            }
 896        }
 897        return builder.build();
 898    }
 899
 900    public static MucOptions.User itemToUser(
 901            final Conversation conference,
 902            im.conversations.android.xmpp.model.muc.Item item,
 903            final Jid from,
 904            final String occupantId,
 905            final String nicknameIn,
 906            final Element hatsEl) {
 907        final var affiliation = item.getAffiliation();
 908        final var role = item.getRole();
 909        var nick = item.getNick();
 910        try {
 911            if (nicknameIn != null && nick != null && !nick.equals(nicknameIn) && gnu.inet.encoding.Punycode.decode(nick).equals(nicknameIn)) {
 912                nick = nicknameIn;
 913            }
 914        } catch (final Exception e) { }
 915        Set<MucOptions.Hat> hats = new TreeSet<>();
 916        if (hatsEl != null) {
 917            for (final var hat : hatsEl.getChildren()) {
 918                if ("hat".equals(hat.getName()) && ("urn:xmpp:hats:0".equals(hat.getNamespace()) || "xmpp:prosody.im/protocol/hats:1".equals(hat.getNamespace()))) {
 919                    hats.add(new MucOptions.Hat(hat));
 920                }
 921            }
 922        }
 923        final Jid fullAddress;
 924        if (from != null && from.isFullJid()) {
 925            fullAddress = from;
 926        } else if (Strings.isNullOrEmpty(nick)) {
 927            fullAddress = null;
 928        } else {
 929            fullAddress = ofNick(conference, nick);
 930        }
 931        final Jid realJid = item.getAttributeAsJid("jid");
 932        MucOptions.User user = new MucOptions.User(conference.getMucOptions(), fullAddress, occupantId, nick, hats);
 933        if (Jid.Invalid.isValid(realJid)) {
 934            user.setRealJid(realJid);
 935        }
 936        user.setAffiliation(affiliation);
 937        user.setRole(role);
 938        return user;
 939    }
 940
 941    private static Jid ofNick(final Conversation conversation, final String nick) {
 942        try {
 943            return conversation.getJid().withResource(nick);
 944        } catch (final IllegalArgumentException e) {
 945            return null;
 946        }
 947    }
 948
 949    private static Map<String, Object> modifyBestInteroperability(
 950            final Map<String, Object> unmodified) {
 951        final var builder = new ImmutableMap.Builder<String, Object>();
 952        builder.putAll(unmodified);
 953
 954        if (unmodified.get("muc#roomconfig_moderatedroom") instanceof Boolean moderated) {
 955            builder.put("members_by_default", !moderated);
 956        }
 957        if (unmodified.get("muc#roomconfig_allowpm") instanceof String allowPm) {
 958            // ejabberd :-/
 959            final boolean allow = "anyone".equals(allowPm);
 960            builder.put("allow_private_messages", allow);
 961            builder.put("allow_private_messages_from_visitors", allow ? "anyone" : "nobody");
 962        }
 963
 964        if (unmodified.get("muc#roomconfig_allowinvites") instanceof Boolean allowInvites) {
 965            // TODO check that this actually does something useful?
 966            builder.put(
 967                    "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", allowInvites);
 968        }
 969
 970        return builder.buildOrThrow();
 971    }
 972
 973    private static Map<String, Object> configWithName(
 974            final Map<String, Object> unmodified, final String name) {
 975        if (Strings.isNullOrEmpty(name)) {
 976            return unmodified;
 977        }
 978        return new ImmutableMap.Builder<String, Object>()
 979                .putAll(unmodified)
 980                .put("muc#roomconfig_roomname", name)
 981                .buildKeepingLast();
 982    }
 983
 984    public static Map<String, Object> defaultGroupChatConfiguration() {
 985        return new ImmutableMap.Builder<String, Object>()
 986                .put("muc#roomconfig_persistentroom", true)
 987                .put("muc#roomconfig_membersonly", true)
 988                .put("muc#roomconfig_publicroom", false)
 989                .put("muc#roomconfig_whois", "anyone")
 990                .put("muc#roomconfig_changesubject", false)
 991                .put("muc#roomconfig_allowinvites", false)
 992                .put("muc#roomconfig_enablearchiving", true) // prosody
 993                .put("mam", true) // ejabberd community
 994                .put("muc#roomconfig_mam", true) // ejabberd saas
 995                .buildOrThrow();
 996    }
 997
 998    public static Map<String, Object> defaultChannelConfiguration() {
 999        return new ImmutableMap.Builder<String, Object>()
1000                .put("muc#roomconfig_persistentroom", true)
1001                .put("muc#roomconfig_membersonly", false)
1002                .put("muc#roomconfig_publicroom", true)
1003                .put("muc#roomconfig_whois", "moderators")
1004                .put("muc#roomconfig_changesubject", false)
1005                .put("muc#roomconfig_enablearchiving", true) // prosody
1006                .put("mam", true) // ejabberd community
1007                .put("muc#roomconfig_mam", true) // ejabberd saas
1008                .buildOrThrow();
1009    }
1010}