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