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}