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