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