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