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