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