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