1package eu.siacs.conversations.entities;
2
3import android.content.ContentValues;
4import android.database.Cursor;
5import android.net.Uri;
6import android.text.TextUtils;
7import android.view.LayoutInflater;
8import android.view.View;
9import android.view.ViewGroup;
10import android.webkit.WebView;
11import android.webkit.WebViewClient;
12
13import androidx.annotation.NonNull;
14import androidx.annotation.Nullable;
15import androidx.databinding.DataBindingUtil;
16import androidx.databinding.ViewDataBinding;
17import androidx.viewpager.widget.PagerAdapter;
18import androidx.recyclerview.widget.RecyclerView;
19import androidx.recyclerview.widget.LinearLayoutManager;
20import androidx.viewpager.widget.ViewPager;
21
22import com.google.android.material.tabs.TabLayout;
23import com.google.common.collect.ComparisonChain;
24import com.google.common.collect.Lists;
25
26import org.json.JSONArray;
27import org.json.JSONException;
28import org.json.JSONObject;
29
30import java.util.ArrayList;
31import java.util.Collections;
32import java.util.Iterator;
33import java.util.List;
34import java.util.ListIterator;
35import java.util.concurrent.atomic.AtomicBoolean;
36
37import eu.siacs.conversations.Config;
38import eu.siacs.conversations.R;
39import eu.siacs.conversations.crypto.OmemoSetting;
40import eu.siacs.conversations.crypto.PgpDecryptionService;
41import eu.siacs.conversations.databinding.CommandPageBinding;
42import eu.siacs.conversations.databinding.CommandNoteBinding;
43import eu.siacs.conversations.databinding.CommandWebviewBinding;
44import eu.siacs.conversations.persistance.DatabaseBackend;
45import eu.siacs.conversations.services.AvatarService;
46import eu.siacs.conversations.services.QuickConversationsService;
47import eu.siacs.conversations.services.XmppConnectionService;
48import eu.siacs.conversations.utils.JidHelper;
49import eu.siacs.conversations.utils.MessageUtils;
50import eu.siacs.conversations.utils.UIHelper;
51import eu.siacs.conversations.xml.Element;
52import eu.siacs.conversations.xml.Namespace;
53import eu.siacs.conversations.xmpp.Jid;
54import eu.siacs.conversations.xmpp.chatstate.ChatState;
55import eu.siacs.conversations.xmpp.mam.MamReference;
56import eu.siacs.conversations.xmpp.stanzas.IqPacket;
57
58import static eu.siacs.conversations.entities.Bookmark.printableValue;
59
60
61public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
62 public static final String TABLENAME = "conversations";
63
64 public static final int STATUS_AVAILABLE = 0;
65 public static final int STATUS_ARCHIVED = 1;
66
67 public static final String NAME = "name";
68 public static final String ACCOUNT = "accountUuid";
69 public static final String CONTACT = "contactUuid";
70 public static final String CONTACTJID = "contactJid";
71 public static final String STATUS = "status";
72 public static final String CREATED = "created";
73 public static final String MODE = "mode";
74 public static final String ATTRIBUTES = "attributes";
75
76 public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
77 public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
78 public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
79 public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
80 public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
81 static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
82 static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
83 static final String ATTRIBUTE_MODERATED = "moderated";
84 static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
85 private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
86 private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
87 private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
88 private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
89 private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
90 protected final ArrayList<Message> messages = new ArrayList<>();
91 public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
92 protected Account account = null;
93 private String draftMessage;
94 private final String name;
95 private final String contactUuid;
96 private final String accountUuid;
97 private Jid contactJid;
98 private int status;
99 private final long created;
100 private int mode;
101 private JSONObject attributes;
102 private Jid nextCounterpart;
103 private transient MucOptions mucOptions = null;
104 private boolean messagesLeftOnServer = true;
105 private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
106 private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
107 private String mFirstMamReference = null;
108 protected int mCurrentTab = -1;
109 protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
110
111 public Conversation(final String name, final Account account, final Jid contactJid,
112 final int mode) {
113 this(java.util.UUID.randomUUID().toString(), name, null, account
114 .getUuid(), contactJid, System.currentTimeMillis(),
115 STATUS_AVAILABLE, mode, "");
116 this.account = account;
117 }
118
119 public Conversation(final String uuid, final String name, final String contactUuid,
120 final String accountUuid, final Jid contactJid, final long created, final int status,
121 final int mode, final String attributes) {
122 this.uuid = uuid;
123 this.name = name;
124 this.contactUuid = contactUuid;
125 this.accountUuid = accountUuid;
126 this.contactJid = contactJid;
127 this.created = created;
128 this.status = status;
129 this.mode = mode;
130 try {
131 this.attributes = new JSONObject(attributes == null ? "" : attributes);
132 } catch (JSONException e) {
133 this.attributes = new JSONObject();
134 }
135 }
136
137 public static Conversation fromCursor(Cursor cursor) {
138 return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
139 cursor.getString(cursor.getColumnIndex(NAME)),
140 cursor.getString(cursor.getColumnIndex(CONTACT)),
141 cursor.getString(cursor.getColumnIndex(ACCOUNT)),
142 JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
143 cursor.getLong(cursor.getColumnIndex(CREATED)),
144 cursor.getInt(cursor.getColumnIndex(STATUS)),
145 cursor.getInt(cursor.getColumnIndex(MODE)),
146 cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
147 }
148
149 public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
150 for (int i = messages.size() - 1; i >= 0; --i) {
151 final Message message = messages.get(i);
152 if (message.getStatus() <= Message.STATUS_RECEIVED
153 && (message.markable || isPrivateAndNonAnonymousMuc)
154 && !message.isPrivateMessage()) {
155 return message;
156 }
157 }
158 return null;
159 }
160
161 private static boolean suitableForOmemoByDefault(final Conversation conversation) {
162 if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
163 return false;
164 }
165 if (conversation.getContact().isOwnServer()) {
166 return false;
167 }
168 final String contact = conversation.getJid().getDomain().toEscapedString();
169 final String account = conversation.getAccount().getServer();
170 if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
171 return false;
172 }
173 return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
174 }
175
176 public boolean hasMessagesLeftOnServer() {
177 return messagesLeftOnServer;
178 }
179
180 public void setHasMessagesLeftOnServer(boolean value) {
181 this.messagesLeftOnServer = value;
182 }
183
184 public Message getFirstUnreadMessage() {
185 Message first = null;
186 synchronized (this.messages) {
187 for (int i = messages.size() - 1; i >= 0; --i) {
188 if (messages.get(i).isRead()) {
189 return first;
190 } else {
191 first = messages.get(i);
192 }
193 }
194 }
195 return first;
196 }
197
198 public String findMostRecentRemoteDisplayableId() {
199 final boolean multi = mode == Conversation.MODE_MULTI;
200 synchronized (this.messages) {
201 for (final Message message : Lists.reverse(this.messages)) {
202 if (message.getStatus() == Message.STATUS_RECEIVED) {
203 final String serverMsgId = message.getServerMsgId();
204 if (serverMsgId != null && multi) {
205 return serverMsgId;
206 }
207 return message.getRemoteMsgId();
208 }
209 }
210 }
211 return null;
212 }
213
214 public int countFailedDeliveries() {
215 int count = 0;
216 synchronized (this.messages) {
217 for(final Message message : this.messages) {
218 if (message.getStatus() == Message.STATUS_SEND_FAILED) {
219 ++count;
220 }
221 }
222 }
223 return count;
224 }
225
226 public Message getLastEditableMessage() {
227 synchronized (this.messages) {
228 for (final Message message : Lists.reverse(this.messages)) {
229 if (message.isEditable()) {
230 if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
231 return null;
232 }
233 return message;
234 }
235 }
236 }
237 return null;
238 }
239
240
241 public Message findUnsentMessageWithUuid(String uuid) {
242 synchronized (this.messages) {
243 for (final Message message : this.messages) {
244 final int s = message.getStatus();
245 if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
246 return message;
247 }
248 }
249 }
250 return null;
251 }
252
253 public void findWaitingMessages(OnMessageFound onMessageFound) {
254 final ArrayList<Message> results = new ArrayList<>();
255 synchronized (this.messages) {
256 for (Message message : this.messages) {
257 if (message.getStatus() == Message.STATUS_WAITING) {
258 results.add(message);
259 }
260 }
261 }
262 for (Message result : results) {
263 onMessageFound.onMessageFound(result);
264 }
265 }
266
267 public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
268 final ArrayList<Message> results = new ArrayList<>();
269 synchronized (this.messages) {
270 for (final Message message : this.messages) {
271 if (message.isRead()) {
272 continue;
273 }
274 results.add(message);
275 }
276 }
277 for (final Message result : results) {
278 onMessageFound.onMessageFound(result);
279 }
280 }
281
282 public Message findMessageWithFileAndUuid(final String uuid) {
283 synchronized (this.messages) {
284 for (final Message message : this.messages) {
285 final Transferable transferable = message.getTransferable();
286 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
287 if (message.getUuid().equals(uuid)
288 && message.getEncryption() != Message.ENCRYPTION_PGP
289 && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
290 return message;
291 }
292 }
293 }
294 return null;
295 }
296
297 public Message findMessageWithUuid(final String uuid) {
298 synchronized (this.messages) {
299 for (final Message message : this.messages) {
300 if (message.getUuid().equals(uuid)) {
301 return message;
302 }
303 }
304 }
305 return null;
306 }
307
308 public boolean markAsDeleted(final List<String> uuids) {
309 boolean deleted = false;
310 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
311 synchronized (this.messages) {
312 for (Message message : this.messages) {
313 if (uuids.contains(message.getUuid())) {
314 message.setDeleted(true);
315 deleted = true;
316 if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
317 pgpDecryptionService.discard(message);
318 }
319 }
320 }
321 }
322 return deleted;
323 }
324
325 public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
326 boolean changed = false;
327 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
328 synchronized (this.messages) {
329 for (Message message : this.messages) {
330 for (final DatabaseBackend.FilePathInfo file : files)
331 if (file.uuid.toString().equals(message.getUuid())) {
332 message.setDeleted(file.deleted);
333 changed = true;
334 if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
335 pgpDecryptionService.discard(message);
336 }
337 }
338 }
339 }
340 return changed;
341 }
342
343 public void clearMessages() {
344 synchronized (this.messages) {
345 this.messages.clear();
346 }
347 }
348
349 public boolean setIncomingChatState(ChatState state) {
350 if (this.mIncomingChatState == state) {
351 return false;
352 }
353 this.mIncomingChatState = state;
354 return true;
355 }
356
357 public ChatState getIncomingChatState() {
358 return this.mIncomingChatState;
359 }
360
361 public boolean setOutgoingChatState(ChatState state) {
362 if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
363 if (this.mOutgoingChatState != state) {
364 this.mOutgoingChatState = state;
365 return true;
366 }
367 }
368 return false;
369 }
370
371 public ChatState getOutgoingChatState() {
372 return this.mOutgoingChatState;
373 }
374
375 public void trim() {
376 synchronized (this.messages) {
377 final int size = messages.size();
378 final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
379 if (size > maxsize) {
380 List<Message> discards = this.messages.subList(0, size - maxsize);
381 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
382 if (pgpDecryptionService != null) {
383 pgpDecryptionService.discard(discards);
384 }
385 discards.clear();
386 untieMessages();
387 }
388 }
389 }
390
391 public void findUnsentTextMessages(OnMessageFound onMessageFound) {
392 final ArrayList<Message> results = new ArrayList<>();
393 synchronized (this.messages) {
394 for (Message message : this.messages) {
395 if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
396 results.add(message);
397 }
398 }
399 }
400 for (Message result : results) {
401 onMessageFound.onMessageFound(result);
402 }
403 }
404
405 public Message findSentMessageWithUuidOrRemoteId(String id) {
406 synchronized (this.messages) {
407 for (Message message : this.messages) {
408 if (id.equals(message.getUuid())
409 || (message.getStatus() >= Message.STATUS_SEND
410 && id.equals(message.getRemoteMsgId()))) {
411 return message;
412 }
413 }
414 }
415 return null;
416 }
417
418 public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
419 synchronized (this.messages) {
420 for (int i = this.messages.size() - 1; i >= 0; --i) {
421 final Message message = messages.get(i);
422 final Jid mcp = message.getCounterpart();
423 if (mcp == null) {
424 continue;
425 }
426 if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
427 && (carbon == message.isCarbon() || received)) {
428 final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
429 if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
430 return message;
431 } else {
432 return null;
433 }
434 }
435 }
436 }
437 return null;
438 }
439
440 public Message findSentMessageWithUuid(String id) {
441 synchronized (this.messages) {
442 for (Message message : this.messages) {
443 if (id.equals(message.getUuid())) {
444 return message;
445 }
446 }
447 }
448 return null;
449 }
450
451 public Message findMessageWithRemoteId(String id, Jid counterpart) {
452 synchronized (this.messages) {
453 for (Message message : this.messages) {
454 if (counterpart.equals(message.getCounterpart())
455 && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
456 return message;
457 }
458 }
459 }
460 return null;
461 }
462
463 public Message findMessageWithServerMsgId(String id) {
464 synchronized (this.messages) {
465 for (Message message : this.messages) {
466 if (id != null && id.equals(message.getServerMsgId())) {
467 return message;
468 }
469 }
470 }
471 return null;
472 }
473
474 public boolean hasMessageWithCounterpart(Jid counterpart) {
475 synchronized (this.messages) {
476 for (Message message : this.messages) {
477 if (counterpart.equals(message.getCounterpart())) {
478 return true;
479 }
480 }
481 }
482 return false;
483 }
484
485 public void populateWithMessages(final List<Message> messages) {
486 synchronized (this.messages) {
487 messages.clear();
488 messages.addAll(this.messages);
489 }
490 for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
491 if (iterator.next().wasMergedIntoPrevious()) {
492 iterator.remove();
493 }
494 }
495 }
496
497 @Override
498 public boolean isBlocked() {
499 return getContact().isBlocked();
500 }
501
502 @Override
503 public boolean isDomainBlocked() {
504 return getContact().isDomainBlocked();
505 }
506
507 @Override
508 public Jid getBlockedJid() {
509 return getContact().getBlockedJid();
510 }
511
512 public int countMessages() {
513 synchronized (this.messages) {
514 return this.messages.size();
515 }
516 }
517
518 public String getFirstMamReference() {
519 return this.mFirstMamReference;
520 }
521
522 public void setFirstMamReference(String reference) {
523 this.mFirstMamReference = reference;
524 }
525
526 public void setLastClearHistory(long time, String reference) {
527 if (reference != null) {
528 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
529 } else {
530 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
531 }
532 }
533
534 public MamReference getLastClearHistory() {
535 return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
536 }
537
538 public List<Jid> getAcceptedCryptoTargets() {
539 if (mode == MODE_SINGLE) {
540 return Collections.singletonList(getJid().asBareJid());
541 } else {
542 return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
543 }
544 }
545
546 public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
547 setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
548 }
549
550 public boolean setCorrectingMessage(Message correctingMessage) {
551 setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
552 return correctingMessage == null && draftMessage != null;
553 }
554
555 public Message getCorrectingMessage() {
556 final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
557 return uuid == null ? null : findSentMessageWithUuid(uuid);
558 }
559
560 public boolean withSelf() {
561 return getContact().isSelf();
562 }
563
564 @Override
565 public int compareTo(@NonNull Conversation another) {
566 return ComparisonChain.start()
567 .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
568 .compare(another.getSortableTime(), getSortableTime())
569 .result();
570 }
571
572 private long getSortableTime() {
573 Draft draft = getDraft();
574 long messageTime = getLatestMessage().getTimeSent();
575 if (draft == null) {
576 return messageTime;
577 } else {
578 return Math.max(messageTime, draft.getTimestamp());
579 }
580 }
581
582 public String getDraftMessage() {
583 return draftMessage;
584 }
585
586 public void setDraftMessage(String draftMessage) {
587 this.draftMessage = draftMessage;
588 }
589
590 public boolean isRead() {
591 synchronized (this.messages) {
592 for(final Message message : Lists.reverse(this.messages)) {
593 if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
594 continue;
595 }
596 return message.isRead();
597 }
598 return true;
599 }
600 }
601
602 public List<Message> markRead(String upToUuid) {
603 final List<Message> unread = new ArrayList<>();
604 synchronized (this.messages) {
605 for (Message message : this.messages) {
606 if (!message.isRead()) {
607 message.markRead();
608 unread.add(message);
609 }
610 if (message.getUuid().equals(upToUuid)) {
611 return unread;
612 }
613 }
614 }
615 return unread;
616 }
617
618 public Message getLatestMessage() {
619 synchronized (this.messages) {
620 if (this.messages.size() == 0) {
621 Message message = new Message(this, "", Message.ENCRYPTION_NONE);
622 message.setType(Message.TYPE_STATUS);
623 message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
624 return message;
625 } else {
626 return this.messages.get(this.messages.size() - 1);
627 }
628 }
629 }
630
631 public @NonNull
632 CharSequence getName() {
633 if (getMode() == MODE_MULTI) {
634 final String roomName = getMucOptions().getName();
635 final String subject = getMucOptions().getSubject();
636 final Bookmark bookmark = getBookmark();
637 final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
638 if (printableValue(roomName)) {
639 return roomName;
640 } else if (printableValue(subject)) {
641 return subject;
642 } else if (printableValue(bookmarkName, false)) {
643 return bookmarkName;
644 } else {
645 final String generatedName = getMucOptions().createNameFromParticipants();
646 if (printableValue(generatedName)) {
647 return generatedName;
648 } else {
649 return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
650 }
651 }
652 } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
653 return contactJid;
654 } else {
655 return this.getContact().getDisplayName();
656 }
657 }
658
659 public String getAccountUuid() {
660 return this.accountUuid;
661 }
662
663 public Account getAccount() {
664 return this.account;
665 }
666
667 public void setAccount(final Account account) {
668 this.account = account;
669 }
670
671 public Contact getContact() {
672 return this.account.getRoster().getContact(this.contactJid);
673 }
674
675 @Override
676 public Jid getJid() {
677 return this.contactJid;
678 }
679
680 public int getStatus() {
681 return this.status;
682 }
683
684 public void setStatus(int status) {
685 this.status = status;
686 }
687
688 public long getCreated() {
689 return this.created;
690 }
691
692 public ContentValues getContentValues() {
693 ContentValues values = new ContentValues();
694 values.put(UUID, uuid);
695 values.put(NAME, name);
696 values.put(CONTACT, contactUuid);
697 values.put(ACCOUNT, accountUuid);
698 values.put(CONTACTJID, contactJid.toString());
699 values.put(CREATED, created);
700 values.put(STATUS, status);
701 values.put(MODE, mode);
702 synchronized (this.attributes) {
703 values.put(ATTRIBUTES, attributes.toString());
704 }
705 return values;
706 }
707
708 public int getMode() {
709 return this.mode;
710 }
711
712 public void setMode(int mode) {
713 this.mode = mode;
714 }
715
716 /**
717 * short for is Private and Non-anonymous
718 */
719 public boolean isSingleOrPrivateAndNonAnonymous() {
720 return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
721 }
722
723 public boolean isPrivateAndNonAnonymous() {
724 return getMucOptions().isPrivateAndNonAnonymous();
725 }
726
727 public synchronized MucOptions getMucOptions() {
728 if (this.mucOptions == null) {
729 this.mucOptions = new MucOptions(this);
730 }
731 return this.mucOptions;
732 }
733
734 public void resetMucOptions() {
735 this.mucOptions = null;
736 }
737
738 public void setContactJid(final Jid jid) {
739 this.contactJid = jid;
740 }
741
742 public Jid getNextCounterpart() {
743 return this.nextCounterpart;
744 }
745
746 public void setNextCounterpart(Jid jid) {
747 this.nextCounterpart = jid;
748 }
749
750 public int getNextEncryption() {
751 if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
752 return Message.ENCRYPTION_NONE;
753 }
754 if (OmemoSetting.isAlways()) {
755 return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
756 }
757 final int defaultEncryption;
758 if (suitableForOmemoByDefault(this)) {
759 defaultEncryption = OmemoSetting.getEncryption();
760 } else {
761 defaultEncryption = Message.ENCRYPTION_NONE;
762 }
763 int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
764 if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
765 return defaultEncryption;
766 } else {
767 return encryption;
768 }
769 }
770
771 public boolean setNextEncryption(int encryption) {
772 return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
773 }
774
775 public String getNextMessage() {
776 final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
777 return nextMessage == null ? "" : nextMessage;
778 }
779
780 public @Nullable
781 Draft getDraft() {
782 long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
783 if (timestamp > getLatestMessage().getTimeSent()) {
784 String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
785 if (!TextUtils.isEmpty(message) && timestamp != 0) {
786 return new Draft(message, timestamp);
787 }
788 }
789 return null;
790 }
791
792 public boolean setNextMessage(final String input) {
793 final String message = input == null || input.trim().isEmpty() ? null : input;
794 boolean changed = !getNextMessage().equals(message);
795 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
796 if (changed) {
797 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
798 }
799 return changed;
800 }
801
802 public Bookmark getBookmark() {
803 return this.account.getBookmark(this.contactJid);
804 }
805
806 public Message findDuplicateMessage(Message message) {
807 synchronized (this.messages) {
808 for (int i = this.messages.size() - 1; i >= 0; --i) {
809 if (this.messages.get(i).similar(message)) {
810 return this.messages.get(i);
811 }
812 }
813 }
814 return null;
815 }
816
817 public boolean hasDuplicateMessage(Message message) {
818 return findDuplicateMessage(message) != null;
819 }
820
821 public Message findSentMessageWithBody(String body) {
822 synchronized (this.messages) {
823 for (int i = this.messages.size() - 1; i >= 0; --i) {
824 Message message = this.messages.get(i);
825 if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
826 String otherBody;
827 if (message.hasFileOnRemoteHost()) {
828 otherBody = message.getFileParams().url;
829 } else {
830 otherBody = message.body;
831 }
832 if (otherBody != null && otherBody.equals(body)) {
833 return message;
834 }
835 }
836 }
837 return null;
838 }
839 }
840
841 public Message findRtpSession(final String sessionId, final int s) {
842 synchronized (this.messages) {
843 for (int i = this.messages.size() - 1; i >= 0; --i) {
844 final Message message = this.messages.get(i);
845 if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
846 return message;
847 }
848 }
849 }
850 return null;
851 }
852
853 public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
854 if (serverMsgId == null || remoteMsgId == null) {
855 return false;
856 }
857 synchronized (this.messages) {
858 for (Message message : this.messages) {
859 if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
860 return true;
861 }
862 }
863 }
864 return false;
865 }
866
867 public MamReference getLastMessageTransmitted() {
868 final MamReference lastClear = getLastClearHistory();
869 MamReference lastReceived = new MamReference(0);
870 synchronized (this.messages) {
871 for (int i = this.messages.size() - 1; i >= 0; --i) {
872 final Message message = this.messages.get(i);
873 if (message.isPrivateMessage()) {
874 continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
875 }
876 if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
877 lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
878 break;
879 }
880 }
881 }
882 return MamReference.max(lastClear, lastReceived);
883 }
884
885 public void setMutedTill(long value) {
886 this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
887 }
888
889 public boolean isMuted() {
890 return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
891 }
892
893 public boolean alwaysNotify() {
894 return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
895 }
896
897 public boolean setAttribute(String key, boolean value) {
898 return setAttribute(key, String.valueOf(value));
899 }
900
901 private boolean setAttribute(String key, long value) {
902 return setAttribute(key, Long.toString(value));
903 }
904
905 private boolean setAttribute(String key, int value) {
906 return setAttribute(key, String.valueOf(value));
907 }
908
909 public boolean setAttribute(String key, String value) {
910 synchronized (this.attributes) {
911 try {
912 if (value == null) {
913 if (this.attributes.has(key)) {
914 this.attributes.remove(key);
915 return true;
916 } else {
917 return false;
918 }
919 } else {
920 final String prev = this.attributes.optString(key, null);
921 this.attributes.put(key, value);
922 return !value.equals(prev);
923 }
924 } catch (JSONException e) {
925 throw new AssertionError(e);
926 }
927 }
928 }
929
930 public boolean setAttribute(String key, List<Jid> jids) {
931 JSONArray array = new JSONArray();
932 for (Jid jid : jids) {
933 array.put(jid.asBareJid().toString());
934 }
935 synchronized (this.attributes) {
936 try {
937 this.attributes.put(key, array);
938 return true;
939 } catch (JSONException e) {
940 return false;
941 }
942 }
943 }
944
945 public String getAttribute(String key) {
946 synchronized (this.attributes) {
947 return this.attributes.optString(key, null);
948 }
949 }
950
951 private List<Jid> getJidListAttribute(String key) {
952 ArrayList<Jid> list = new ArrayList<>();
953 synchronized (this.attributes) {
954 try {
955 JSONArray array = this.attributes.getJSONArray(key);
956 for (int i = 0; i < array.length(); ++i) {
957 try {
958 list.add(Jid.of(array.getString(i)));
959 } catch (IllegalArgumentException e) {
960 //ignored
961 }
962 }
963 } catch (JSONException e) {
964 //ignored
965 }
966 }
967 return list;
968 }
969
970 private int getIntAttribute(String key, int defaultValue) {
971 String value = this.getAttribute(key);
972 if (value == null) {
973 return defaultValue;
974 } else {
975 try {
976 return Integer.parseInt(value);
977 } catch (NumberFormatException e) {
978 return defaultValue;
979 }
980 }
981 }
982
983 public long getLongAttribute(String key, long defaultValue) {
984 String value = this.getAttribute(key);
985 if (value == null) {
986 return defaultValue;
987 } else {
988 try {
989 return Long.parseLong(value);
990 } catch (NumberFormatException e) {
991 return defaultValue;
992 }
993 }
994 }
995
996 public boolean getBooleanAttribute(String key, boolean defaultValue) {
997 String value = this.getAttribute(key);
998 if (value == null) {
999 return defaultValue;
1000 } else {
1001 return Boolean.parseBoolean(value);
1002 }
1003 }
1004
1005 public void add(Message message) {
1006 synchronized (this.messages) {
1007 this.messages.add(message);
1008 }
1009 }
1010
1011 public void prepend(int offset, Message message) {
1012 synchronized (this.messages) {
1013 this.messages.add(Math.min(offset, this.messages.size()), message);
1014 }
1015 }
1016
1017 public void addAll(int index, List<Message> messages) {
1018 synchronized (this.messages) {
1019 this.messages.addAll(index, messages);
1020 }
1021 account.getPgpDecryptionService().decrypt(messages);
1022 }
1023
1024 public void expireOldMessages(long timestamp) {
1025 synchronized (this.messages) {
1026 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1027 if (iterator.next().getTimeSent() < timestamp) {
1028 iterator.remove();
1029 }
1030 }
1031 untieMessages();
1032 }
1033 }
1034
1035 public void sort() {
1036 synchronized (this.messages) {
1037 Collections.sort(this.messages, (left, right) -> {
1038 if (left.getTimeSent() < right.getTimeSent()) {
1039 return -1;
1040 } else if (left.getTimeSent() > right.getTimeSent()) {
1041 return 1;
1042 } else {
1043 return 0;
1044 }
1045 });
1046 untieMessages();
1047 }
1048 }
1049
1050 private void untieMessages() {
1051 for (Message message : this.messages) {
1052 message.untie();
1053 }
1054 }
1055
1056 public int unreadCount() {
1057 synchronized (this.messages) {
1058 int count = 0;
1059 for(final Message message : Lists.reverse(this.messages)) {
1060 if (message.isRead()) {
1061 if (message.getType() == Message.TYPE_RTP_SESSION) {
1062 continue;
1063 }
1064 return count;
1065 }
1066 ++count;
1067 }
1068 return count;
1069 }
1070 }
1071
1072 public int receivedMessagesCount() {
1073 int count = 0;
1074 synchronized (this.messages) {
1075 for (Message message : messages) {
1076 if (message.getStatus() == Message.STATUS_RECEIVED) {
1077 ++count;
1078 }
1079 }
1080 }
1081 return count;
1082 }
1083
1084 public int sentMessagesCount() {
1085 int count = 0;
1086 synchronized (this.messages) {
1087 for (Message message : messages) {
1088 if (message.getStatus() != Message.STATUS_RECEIVED) {
1089 ++count;
1090 }
1091 }
1092 }
1093 return count;
1094 }
1095
1096 public boolean isWithStranger() {
1097 final Contact contact = getContact();
1098 return mode == MODE_SINGLE
1099 && !contact.isOwnServer()
1100 && !contact.showInContactList()
1101 && !contact.isSelf()
1102 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1103 && sentMessagesCount() == 0;
1104 }
1105
1106 public int getReceivedMessagesCountSinceUuid(String uuid) {
1107 if (uuid == null) {
1108 return 0;
1109 }
1110 int count = 0;
1111 synchronized (this.messages) {
1112 for (int i = messages.size() - 1; i >= 0; i--) {
1113 final Message message = messages.get(i);
1114 if (uuid.equals(message.getUuid())) {
1115 return count;
1116 }
1117 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1118 ++count;
1119 }
1120 }
1121 }
1122 return 0;
1123 }
1124
1125 @Override
1126 public int getAvatarBackgroundColor() {
1127 return UIHelper.getColorForName(getName().toString());
1128 }
1129
1130 @Override
1131 public String getAvatarName() {
1132 return getName().toString();
1133 }
1134
1135 public void setCurrentTab(int tab) {
1136 mCurrentTab = tab;
1137 }
1138
1139 public int getCurrentTab() {
1140 if (mCurrentTab >= 0) return mCurrentTab;
1141
1142 if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1143 return 0;
1144 }
1145
1146 return 1;
1147 }
1148
1149 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1150 pagerAdapter.startCommand(command, xmppConnectionService);
1151 }
1152
1153 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1154 pagerAdapter.setupViewPager(pager, tabs);
1155 }
1156
1157 public interface OnMessageFound {
1158 void onMessageFound(final Message message);
1159 }
1160
1161 public static class Draft {
1162 private final String message;
1163 private final long timestamp;
1164
1165 private Draft(String message, long timestamp) {
1166 this.message = message;
1167 this.timestamp = timestamp;
1168 }
1169
1170 public long getTimestamp() {
1171 return timestamp;
1172 }
1173
1174 public String getMessage() {
1175 return message;
1176 }
1177 }
1178
1179 public class ConversationPagerAdapter extends PagerAdapter {
1180 protected ViewPager mPager = null;
1181 protected TabLayout mTabs = null;
1182 ArrayList<CommandSession> sessions = new ArrayList<>();
1183
1184 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1185 mPager = pager;
1186 mTabs = tabs;
1187 pager.setAdapter(this);
1188 tabs.setupWithViewPager(mPager);
1189 pager.setCurrentItem(getCurrentTab());
1190
1191 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1192 public void onPageScrollStateChanged(int state) { }
1193 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1194
1195 public void onPageSelected(int position) {
1196 setCurrentTab(position);
1197 }
1198 });
1199 }
1200
1201 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1202 CommandSession session = new CommandSession(command.getAttribute("name"));
1203
1204 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1205 packet.setTo(command.getAttributeAsJid("jid"));
1206 final Element c = packet.addChild("command", Namespace.COMMANDS);
1207 c.setAttribute("node", command.getAttribute("node"));
1208 c.setAttribute("action", "execute");
1209 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1210 mPager.post(() -> {
1211 session.updateWithResponse(iq);
1212 });
1213 });
1214
1215 sessions.add(session);
1216 notifyDataSetChanged();
1217 mPager.setCurrentItem(getCount() - 1);
1218 }
1219
1220 @NonNull
1221 @Override
1222 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1223 if (position < 2) {
1224 return mPager.getChildAt(position);
1225 }
1226
1227 CommandSession session = sessions.get(position-2);
1228 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1229 container.addView(binding.getRoot());
1230 binding.form.setAdapter(session);
1231 binding.done.setOnClickListener((button) -> {
1232 sessions.remove(session);
1233 notifyDataSetChanged();
1234 });
1235
1236 session.setBinding(binding);
1237 return session;
1238 }
1239
1240 @Override
1241 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1242 if (position < 2) return;
1243
1244 container.removeView(((CommandSession) o).getView());
1245 }
1246
1247 @Override
1248 public int getItemPosition(Object o) {
1249 if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
1250 if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;
1251
1252 int pos = sessions.indexOf(o);
1253 if (pos < 0) return PagerAdapter.POSITION_NONE;
1254 return pos + 2;
1255 }
1256
1257 @Override
1258 public int getCount() {
1259 int count = 2 + sessions.size();
1260 if (count > 2) {
1261 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1262 } else {
1263 mTabs.setTabMode(TabLayout.MODE_FIXED);
1264 }
1265 return count;
1266 }
1267
1268 @Override
1269 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1270 if (view == o) return true;
1271
1272 if (o instanceof CommandSession) {
1273 return ((CommandSession) o).getView() == view;
1274 }
1275
1276 return false;
1277 }
1278
1279 @Nullable
1280 @Override
1281 public CharSequence getPageTitle(int position) {
1282 switch (position) {
1283 case 0:
1284 return "Conversation";
1285 case 1:
1286 return "Commands";
1287 default:
1288 CommandSession session = sessions.get(position-2);
1289 if (session == null) return super.getPageTitle(position);
1290 return session.getTitle();
1291 }
1292 }
1293
1294 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1295 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1296 protected T binding;
1297
1298 public ViewHolder(T binding) {
1299 super(binding.getRoot());
1300 this.binding = binding;
1301 }
1302
1303 abstract public void bind(Element el, int position);
1304 }
1305
1306 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1307 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1308
1309 @Override
1310 public void bind(Element iq, int position) {
1311 binding.errorIcon.setVisibility(View.VISIBLE);
1312
1313 Element error = iq.findChild("error");
1314 if (error == null) return;
1315 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1316 if (text == null || text.equals("")) {
1317 text = error.getChildren().get(0).getName();
1318 }
1319 binding.message.setText(text);
1320 }
1321 }
1322
1323 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1324 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1325
1326 @Override
1327 public void bind(Element note, int position) {
1328 binding.message.setText(note.getContent());
1329
1330 if (note.getAttribute("type").equals("error")) {
1331 binding.errorIcon.setVisibility(View.VISIBLE);
1332 }
1333 }
1334 }
1335
1336 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1337 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1338
1339 @Override
1340 public void bind(Element oob, int position) {
1341 binding.webview.getSettings().setJavaScriptEnabled(true);
1342 binding.webview.setWebViewClient(new WebViewClient() {
1343 @Override
1344 public void onPageFinished(WebView view, String url) {
1345 super.onPageFinished(view, url);
1346 mTitle = view.getTitle();
1347 ConversationPagerAdapter.this.notifyDataSetChanged();
1348 }
1349 });
1350 binding.webview.loadUrl(oob.findChildContent("url", "jabber:x:oob"));
1351 }
1352 }
1353
1354 final int TYPE_ERROR = 1;
1355 final int TYPE_NOTE = 2;
1356 final int TYPE_WEB = 3;
1357
1358 protected String mTitle;
1359 protected CommandPageBinding mBinding = null;
1360 protected IqPacket response = null;
1361 protected Element responseElement = null;
1362
1363 CommandSession(String title) {
1364 mTitle = title;
1365 }
1366
1367 public String getTitle() {
1368 return mTitle;
1369 }
1370
1371 public void updateWithResponse(IqPacket iq) {
1372 this.responseElement = null;
1373 this.response = iq;
1374
1375 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
1376 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
1377 for (Element el : command.getChildren()) {
1378 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
1379 String url = el.findChildContent("url", "jabber:x:oob");
1380 if (url != null) {
1381 String scheme = Uri.parse(url).getScheme();
1382 if (scheme.equals("http") || scheme.equals("https")) {
1383 this.responseElement = el;
1384 break;
1385 }
1386 }
1387 }
1388 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
1389 this.responseElement = el;
1390 break;
1391 }
1392 }
1393 }
1394
1395 notifyDataSetChanged();
1396 }
1397
1398 @Override
1399 public int getItemCount() {
1400 if (response == null) return 0;
1401 return 1;
1402 }
1403
1404 @Override
1405 public int getItemViewType(int position) {
1406 if (response == null) return -1;
1407
1408 if (response.getType() == IqPacket.TYPE.RESULT) {
1409 if (responseElement.getName().equals("note")) return TYPE_NOTE;
1410 if (responseElement.getNamespace().equals("jabber:x:oob")) return TYPE_WEB;
1411 return -1;
1412 } else {
1413 return TYPE_ERROR;
1414 }
1415 }
1416
1417 @Override
1418 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
1419 switch(viewType) {
1420 case TYPE_ERROR: {
1421 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1422 return new ErrorViewHolder(binding);
1423 }
1424 case TYPE_NOTE: {
1425 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1426 return new NoteViewHolder(binding);
1427 }
1428 case TYPE_WEB: {
1429 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
1430 return new WebViewHolder(binding);
1431 }
1432 default:
1433 return null;
1434 }
1435 }
1436
1437 @Override
1438 public void onBindViewHolder(ViewHolder viewHolder, int position) {
1439 viewHolder.bind(responseElement == null ? response : responseElement, position);
1440 }
1441
1442 public View getView() {
1443 return mBinding.getRoot();
1444 }
1445
1446 public void setBinding(CommandPageBinding b) {
1447 mBinding = b;
1448 mBinding.form.setLayoutManager(new LinearLayoutManager(mPager.getContext()) {
1449 @Override
1450 public boolean canScrollVertically() { return getItemCount() > 1; }
1451 });
1452 }
1453 }
1454 }
1455}