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}