Conversation.java

   1package eu.siacs.conversations.entities;
   2
   3import android.content.ContentValues;
   4import android.content.Context;
   5import android.database.Cursor;
   6import android.database.DataSetObserver;
   7import android.graphics.Rect;
   8import android.net.Uri;
   9import android.text.Editable;
  10import android.text.InputType;
  11import android.text.SpannableStringBuilder;
  12import android.text.Spanned;
  13import android.text.StaticLayout;
  14import android.text.TextPaint;
  15import android.text.TextUtils;
  16import android.text.TextWatcher;
  17import android.view.LayoutInflater;
  18import android.view.MotionEvent;
  19import android.view.Gravity;
  20import android.view.View;
  21import android.view.ViewGroup;
  22import android.widget.ArrayAdapter;
  23import android.widget.AdapterView;
  24import android.widget.CompoundButton;
  25import android.widget.GridLayout;
  26import android.widget.ListView;
  27import android.widget.TextView;
  28import android.widget.Toast;
  29import android.widget.Spinner;
  30import android.webkit.JavascriptInterface;
  31import android.webkit.WebMessage;
  32import android.webkit.WebView;
  33import android.webkit.WebViewClient;
  34import android.webkit.WebChromeClient;
  35import android.util.Pair;
  36import android.util.SparseArray;
  37
  38import androidx.annotation.NonNull;
  39import androidx.annotation.Nullable;
  40import androidx.core.content.ContextCompat;
  41import androidx.databinding.DataBindingUtil;
  42import androidx.databinding.ViewDataBinding;
  43import androidx.viewpager.widget.PagerAdapter;
  44import androidx.recyclerview.widget.RecyclerView;
  45import androidx.recyclerview.widget.GridLayoutManager;
  46import androidx.viewpager.widget.ViewPager;
  47
  48import com.google.android.material.tabs.TabLayout;
  49import com.google.android.material.textfield.TextInputLayout;
  50import com.google.common.base.Optional;
  51import com.google.common.collect.ComparisonChain;
  52import com.google.common.collect.Lists;
  53
  54import org.json.JSONArray;
  55import org.json.JSONException;
  56import org.json.JSONObject;
  57
  58import java.util.ArrayList;
  59import java.util.Collections;
  60import java.util.Iterator;
  61import java.util.List;
  62import java.util.ListIterator;
  63import java.util.concurrent.atomic.AtomicBoolean;
  64import java.util.stream.Collectors;
  65import java.util.Timer;
  66import java.util.TimerTask;
  67
  68import me.saket.bettermovementmethod.BetterLinkMovementMethod;
  69
  70import eu.siacs.conversations.Config;
  71import eu.siacs.conversations.R;
  72import eu.siacs.conversations.crypto.OmemoSetting;
  73import eu.siacs.conversations.crypto.PgpDecryptionService;
  74import eu.siacs.conversations.databinding.CommandPageBinding;
  75import eu.siacs.conversations.databinding.CommandNoteBinding;
  76import eu.siacs.conversations.databinding.CommandResultFieldBinding;
  77import eu.siacs.conversations.databinding.CommandResultCellBinding;
  78import eu.siacs.conversations.databinding.CommandItemCardBinding;
  79import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
  80import eu.siacs.conversations.databinding.CommandProgressBarBinding;
  81import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
  82import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
  83import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
  84import eu.siacs.conversations.databinding.CommandTextFieldBinding;
  85import eu.siacs.conversations.databinding.CommandWebviewBinding;
  86import eu.siacs.conversations.persistance.DatabaseBackend;
  87import eu.siacs.conversations.services.AvatarService;
  88import eu.siacs.conversations.services.QuickConversationsService;
  89import eu.siacs.conversations.services.XmppConnectionService;
  90import eu.siacs.conversations.ui.text.FixedURLSpan;
  91import eu.siacs.conversations.ui.util.ShareUtil;
  92import eu.siacs.conversations.utils.JidHelper;
  93import eu.siacs.conversations.utils.MessageUtils;
  94import eu.siacs.conversations.utils.UIHelper;
  95import eu.siacs.conversations.xml.Element;
  96import eu.siacs.conversations.xml.Namespace;
  97import eu.siacs.conversations.xmpp.Jid;
  98import eu.siacs.conversations.xmpp.chatstate.ChatState;
  99import eu.siacs.conversations.xmpp.forms.Data;
 100import eu.siacs.conversations.xmpp.forms.Option;
 101import eu.siacs.conversations.xmpp.mam.MamReference;
 102import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 103
 104import static eu.siacs.conversations.entities.Bookmark.printableValue;
 105
 106
 107public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
 108    public static final String TABLENAME = "conversations";
 109
 110    public static final int STATUS_AVAILABLE = 0;
 111    public static final int STATUS_ARCHIVED = 1;
 112
 113    public static final String NAME = "name";
 114    public static final String ACCOUNT = "accountUuid";
 115    public static final String CONTACT = "contactUuid";
 116    public static final String CONTACTJID = "contactJid";
 117    public static final String STATUS = "status";
 118    public static final String CREATED = "created";
 119    public static final String MODE = "mode";
 120    public static final String ATTRIBUTES = "attributes";
 121
 122    public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
 123    public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
 124    public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
 125    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
 126    public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
 127    static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
 128    static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
 129    static final String ATTRIBUTE_MODERATED = "moderated";
 130    static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
 131    private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
 132    private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
 133    private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
 134    private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
 135    private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
 136    protected final ArrayList<Message> messages = new ArrayList<>();
 137    public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
 138    protected Account account = null;
 139    private String draftMessage;
 140    private final String name;
 141    private final String contactUuid;
 142    private final String accountUuid;
 143    private Jid contactJid;
 144    private int status;
 145    private final long created;
 146    private int mode;
 147    private JSONObject attributes;
 148    private Jid nextCounterpart;
 149    private transient MucOptions mucOptions = null;
 150    private boolean messagesLeftOnServer = true;
 151    private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
 152    private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
 153    private String mFirstMamReference = null;
 154    protected int mCurrentTab = -1;
 155    protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
 156    protected Element thread = null;
 157    protected boolean lockThread = false;
 158    protected boolean userSelectedThread = false;
 159
 160    public Conversation(final String name, final Account account, final Jid contactJid,
 161                        final int mode) {
 162        this(java.util.UUID.randomUUID().toString(), name, null, account
 163                        .getUuid(), contactJid, System.currentTimeMillis(),
 164                STATUS_AVAILABLE, mode, "");
 165        this.account = account;
 166    }
 167
 168    public Conversation(final String uuid, final String name, final String contactUuid,
 169                        final String accountUuid, final Jid contactJid, final long created, final int status,
 170                        final int mode, final String attributes) {
 171        this.uuid = uuid;
 172        this.name = name;
 173        this.contactUuid = contactUuid;
 174        this.accountUuid = accountUuid;
 175        this.contactJid = contactJid;
 176        this.created = created;
 177        this.status = status;
 178        this.mode = mode;
 179        try {
 180            this.attributes = new JSONObject(attributes == null ? "" : attributes);
 181        } catch (JSONException e) {
 182            this.attributes = new JSONObject();
 183        }
 184    }
 185
 186    public static Conversation fromCursor(Cursor cursor) {
 187        return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
 188                cursor.getString(cursor.getColumnIndex(NAME)),
 189                cursor.getString(cursor.getColumnIndex(CONTACT)),
 190                cursor.getString(cursor.getColumnIndex(ACCOUNT)),
 191                JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
 192                cursor.getLong(cursor.getColumnIndex(CREATED)),
 193                cursor.getInt(cursor.getColumnIndex(STATUS)),
 194                cursor.getInt(cursor.getColumnIndex(MODE)),
 195                cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
 196    }
 197
 198    public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
 199        for (int i = messages.size() - 1; i >= 0; --i) {
 200            final Message message = messages.get(i);
 201            if (message.getStatus() <= Message.STATUS_RECEIVED
 202                    && (message.markable || isPrivateAndNonAnonymousMuc)
 203                    && !message.isPrivateMessage()) {
 204                return message;
 205            }
 206        }
 207        return null;
 208    }
 209
 210    private static boolean suitableForOmemoByDefault(final Conversation conversation) {
 211        if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
 212            return false;
 213        }
 214        if (conversation.getContact().isOwnServer()) {
 215            return false;
 216        }
 217        final String contact = conversation.getJid().getDomain().toEscapedString();
 218        final String account = conversation.getAccount().getServer();
 219        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
 220            return false;
 221        }
 222        return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
 223    }
 224
 225    public boolean hasMessagesLeftOnServer() {
 226        return messagesLeftOnServer;
 227    }
 228
 229    public void setHasMessagesLeftOnServer(boolean value) {
 230        this.messagesLeftOnServer = value;
 231    }
 232
 233    public Message getFirstUnreadMessage() {
 234        Message first = null;
 235        synchronized (this.messages) {
 236            for (int i = messages.size() - 1; i >= 0; --i) {
 237                if (messages.get(i).isRead()) {
 238                    return first;
 239                } else {
 240                    first = messages.get(i);
 241                }
 242            }
 243        }
 244        return first;
 245    }
 246
 247    public String findMostRecentRemoteDisplayableId() {
 248        final boolean multi = mode == Conversation.MODE_MULTI;
 249        synchronized (this.messages) {
 250            for (final Message message : Lists.reverse(this.messages)) {
 251                if (message.getStatus() == Message.STATUS_RECEIVED) {
 252                    final String serverMsgId = message.getServerMsgId();
 253                    if (serverMsgId != null && multi) {
 254                        return serverMsgId;
 255                    }
 256                    return message.getRemoteMsgId();
 257                }
 258            }
 259        }
 260        return null;
 261    }
 262
 263    public int countFailedDeliveries() {
 264        int count = 0;
 265        synchronized (this.messages) {
 266            for(final Message message : this.messages) {
 267                if (message.getStatus() == Message.STATUS_SEND_FAILED) {
 268                    ++count;
 269                }
 270            }
 271        }
 272        return count;
 273    }
 274
 275    public Message getLastEditableMessage() {
 276        synchronized (this.messages) {
 277            for (final Message message : Lists.reverse(this.messages)) {
 278                if (message.isEditable()) {
 279                    if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
 280                        return null;
 281                    }
 282                    return message;
 283                }
 284            }
 285        }
 286        return null;
 287    }
 288
 289
 290    public Message findUnsentMessageWithUuid(String uuid) {
 291        synchronized (this.messages) {
 292            for (final Message message : this.messages) {
 293                final int s = message.getStatus();
 294                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
 295                    return message;
 296                }
 297            }
 298        }
 299        return null;
 300    }
 301
 302    public void findWaitingMessages(OnMessageFound onMessageFound) {
 303        final ArrayList<Message> results = new ArrayList<>();
 304        synchronized (this.messages) {
 305            for (Message message : this.messages) {
 306                if (message.getStatus() == Message.STATUS_WAITING) {
 307                    results.add(message);
 308                }
 309            }
 310        }
 311        for (Message result : results) {
 312            onMessageFound.onMessageFound(result);
 313        }
 314    }
 315
 316    public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
 317        final ArrayList<Message> results = new ArrayList<>();
 318        synchronized (this.messages) {
 319            for (final Message message : this.messages) {
 320                if (message.isRead()) {
 321                    continue;
 322                }
 323                results.add(message);
 324            }
 325        }
 326        for (final Message result : results) {
 327            onMessageFound.onMessageFound(result);
 328        }
 329    }
 330
 331    public Message findMessageWithFileAndUuid(final String uuid) {
 332        synchronized (this.messages) {
 333            for (final Message message : this.messages) {
 334                final Transferable transferable = message.getTransferable();
 335                final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
 336                if (message.getUuid().equals(uuid)
 337                        && message.getEncryption() != Message.ENCRYPTION_PGP
 338                        && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
 339                    return message;
 340                }
 341            }
 342        }
 343        return null;
 344    }
 345
 346    public Message findMessageWithUuid(final String uuid) {
 347        synchronized (this.messages) {
 348            for (final Message message : this.messages) {
 349                if (message.getUuid().equals(uuid)) {
 350                    return message;
 351                }
 352            }
 353        }
 354        return null;
 355    }
 356
 357    public boolean markAsDeleted(final List<String> uuids) {
 358        boolean deleted = false;
 359        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 360        synchronized (this.messages) {
 361            for (Message message : this.messages) {
 362                if (uuids.contains(message.getUuid())) {
 363                    message.setDeleted(true);
 364                    deleted = true;
 365                    if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 366                        pgpDecryptionService.discard(message);
 367                    }
 368                }
 369            }
 370        }
 371        return deleted;
 372    }
 373
 374    public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
 375        boolean changed = false;
 376        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 377        synchronized (this.messages) {
 378            for (Message message : this.messages) {
 379                for (final DatabaseBackend.FilePathInfo file : files)
 380                    if (file.uuid.toString().equals(message.getUuid())) {
 381                        message.setDeleted(file.deleted);
 382                        changed = true;
 383                        if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 384                            pgpDecryptionService.discard(message);
 385                        }
 386                    }
 387            }
 388        }
 389        return changed;
 390    }
 391
 392    public void clearMessages() {
 393        synchronized (this.messages) {
 394            this.messages.clear();
 395        }
 396    }
 397
 398    public boolean setIncomingChatState(ChatState state) {
 399        if (this.mIncomingChatState == state) {
 400            return false;
 401        }
 402        this.mIncomingChatState = state;
 403        return true;
 404    }
 405
 406    public ChatState getIncomingChatState() {
 407        return this.mIncomingChatState;
 408    }
 409
 410    public boolean setOutgoingChatState(ChatState state) {
 411        if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
 412            if (this.mOutgoingChatState != state) {
 413                this.mOutgoingChatState = state;
 414                return true;
 415            }
 416        }
 417        return false;
 418    }
 419
 420    public ChatState getOutgoingChatState() {
 421        return this.mOutgoingChatState;
 422    }
 423
 424    public void trim() {
 425        synchronized (this.messages) {
 426            final int size = messages.size();
 427            final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
 428            if (size > maxsize) {
 429                List<Message> discards = this.messages.subList(0, size - maxsize);
 430                final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 431                if (pgpDecryptionService != null) {
 432                    pgpDecryptionService.discard(discards);
 433                }
 434                discards.clear();
 435                untieMessages();
 436            }
 437        }
 438    }
 439
 440    public void findUnsentTextMessages(OnMessageFound onMessageFound) {
 441        final ArrayList<Message> results = new ArrayList<>();
 442        synchronized (this.messages) {
 443            for (Message message : this.messages) {
 444                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
 445                    results.add(message);
 446                }
 447            }
 448        }
 449        for (Message result : results) {
 450            onMessageFound.onMessageFound(result);
 451        }
 452    }
 453
 454    public Message findSentMessageWithUuidOrRemoteId(String id) {
 455        synchronized (this.messages) {
 456            for (Message message : this.messages) {
 457                if (id.equals(message.getUuid())
 458                        || (message.getStatus() >= Message.STATUS_SEND
 459                        && id.equals(message.getRemoteMsgId()))) {
 460                    return message;
 461                }
 462            }
 463        }
 464        return null;
 465    }
 466
 467    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
 468        synchronized (this.messages) {
 469            for (int i = this.messages.size() - 1; i >= 0; --i) {
 470                final Message message = messages.get(i);
 471                final Jid mcp = message.getCounterpart();
 472                if (mcp == null) {
 473                    continue;
 474                }
 475                if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
 476                        && (carbon == message.isCarbon() || received)) {
 477                    final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
 478                    if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
 479                        return message;
 480                    } else {
 481                        return null;
 482                    }
 483                }
 484            }
 485        }
 486        return null;
 487    }
 488
 489    public Message findSentMessageWithUuid(String id) {
 490        synchronized (this.messages) {
 491            for (Message message : this.messages) {
 492                if (id.equals(message.getUuid())) {
 493                    return message;
 494                }
 495            }
 496        }
 497        return null;
 498    }
 499
 500    public Message findMessageWithRemoteId(String id, Jid counterpart) {
 501        synchronized (this.messages) {
 502            for (Message message : this.messages) {
 503                if (counterpart.equals(message.getCounterpart())
 504                        && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
 505                    return message;
 506                }
 507            }
 508        }
 509        return null;
 510    }
 511
 512    public Message findMessageWithServerMsgId(String id) {
 513        synchronized (this.messages) {
 514            for (Message message : this.messages) {
 515                if (id != null && id.equals(message.getServerMsgId())) {
 516                    return message;
 517                }
 518            }
 519        }
 520        return null;
 521    }
 522
 523    public boolean hasMessageWithCounterpart(Jid counterpart) {
 524        synchronized (this.messages) {
 525            for (Message message : this.messages) {
 526                if (counterpart.equals(message.getCounterpart())) {
 527                    return true;
 528                }
 529            }
 530        }
 531        return false;
 532    }
 533
 534    public void populateWithMessages(final List<Message> messages) {
 535        synchronized (this.messages) {
 536            messages.clear();
 537            messages.addAll(this.messages);
 538        }
 539        for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
 540            Message m = iterator.next();
 541            if (m.wasMergedIntoPrevious() || (getLockThread() && (m.getThread() == null || !m.getThread().getContent().equals(getThread().getContent())))) {
 542                iterator.remove();
 543            }
 544        }
 545    }
 546
 547    @Override
 548    public boolean isBlocked() {
 549        return getContact().isBlocked();
 550    }
 551
 552    @Override
 553    public boolean isDomainBlocked() {
 554        return getContact().isDomainBlocked();
 555    }
 556
 557    @Override
 558    public Jid getBlockedJid() {
 559        return getContact().getBlockedJid();
 560    }
 561
 562    public int countMessages() {
 563        synchronized (this.messages) {
 564            return this.messages.size();
 565        }
 566    }
 567
 568    public String getFirstMamReference() {
 569        return this.mFirstMamReference;
 570    }
 571
 572    public void setFirstMamReference(String reference) {
 573        this.mFirstMamReference = reference;
 574    }
 575
 576    public void setLastClearHistory(long time, String reference) {
 577        if (reference != null) {
 578            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
 579        } else {
 580            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
 581        }
 582    }
 583
 584    public MamReference getLastClearHistory() {
 585        return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
 586    }
 587
 588    public List<Jid> getAcceptedCryptoTargets() {
 589        if (mode == MODE_SINGLE) {
 590            return Collections.singletonList(getJid().asBareJid());
 591        } else {
 592            return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
 593        }
 594    }
 595
 596    public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
 597        setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
 598    }
 599
 600    public boolean setCorrectingMessage(Message correctingMessage) {
 601        setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
 602        return correctingMessage == null && draftMessage != null;
 603    }
 604
 605    public Message getCorrectingMessage() {
 606        final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
 607        return uuid == null ? null : findSentMessageWithUuid(uuid);
 608    }
 609
 610    public boolean withSelf() {
 611        return getContact().isSelf();
 612    }
 613
 614    @Override
 615    public int compareTo(@NonNull Conversation another) {
 616        return ComparisonChain.start()
 617                .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
 618                .compare(another.getSortableTime(), getSortableTime())
 619                .result();
 620    }
 621
 622    private long getSortableTime() {
 623        Draft draft = getDraft();
 624        long messageTime = getLatestMessage().getTimeReceived();
 625        if (draft == null) {
 626            return messageTime;
 627        } else {
 628            return Math.max(messageTime, draft.getTimestamp());
 629        }
 630    }
 631
 632    public String getDraftMessage() {
 633        return draftMessage;
 634    }
 635
 636    public void setDraftMessage(String draftMessage) {
 637        this.draftMessage = draftMessage;
 638    }
 639
 640    public Element getThread() {
 641        return this.thread;
 642    }
 643
 644    public void setThread(Element thread) {
 645        this.thread = thread;
 646    }
 647
 648    public void setLockThread(boolean flag) {
 649        this.lockThread = flag;
 650        if (flag) setUserSelectedThread(true);
 651    }
 652
 653    public boolean getLockThread() {
 654        return this.lockThread;
 655    }
 656
 657    public void setUserSelectedThread(boolean flag) {
 658        this.userSelectedThread = flag;
 659    }
 660
 661    public boolean getUserSelectedThread() {
 662        return this.userSelectedThread;
 663    }
 664
 665    public boolean isRead() {
 666        synchronized (this.messages) {
 667            for(final Message message : Lists.reverse(this.messages)) {
 668                if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
 669                    continue;
 670                }
 671                return message.isRead();
 672            }
 673            return true;
 674        }
 675    }
 676
 677    public List<Message> markRead(String upToUuid) {
 678        final List<Message> unread = new ArrayList<>();
 679        synchronized (this.messages) {
 680            for (Message message : this.messages) {
 681                if (!message.isRead()) {
 682                    message.markRead();
 683                    unread.add(message);
 684                }
 685                if (message.getUuid().equals(upToUuid)) {
 686                    return unread;
 687                }
 688            }
 689        }
 690        return unread;
 691    }
 692
 693    public Message getLatestMessage() {
 694        synchronized (this.messages) {
 695            if (this.messages.size() == 0) {
 696                Message message = new Message(this, "", Message.ENCRYPTION_NONE);
 697                message.setType(Message.TYPE_STATUS);
 698                message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 699                message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 700                return message;
 701            } else {
 702                return this.messages.get(this.messages.size() - 1);
 703            }
 704        }
 705    }
 706
 707    public @NonNull
 708    CharSequence getName() {
 709        if (getMode() == MODE_MULTI) {
 710            final String roomName = getMucOptions().getName();
 711            final String subject = getMucOptions().getSubject();
 712            final Bookmark bookmark = getBookmark();
 713            final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
 714            if (printableValue(roomName)) {
 715                return roomName;
 716            } else if (printableValue(subject)) {
 717                return subject;
 718            } else if (printableValue(bookmarkName, false)) {
 719                return bookmarkName;
 720            } else {
 721                final String generatedName = getMucOptions().createNameFromParticipants();
 722                if (printableValue(generatedName)) {
 723                    return generatedName;
 724                } else {
 725                    return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
 726                }
 727            }
 728        } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
 729            return contactJid;
 730        } else {
 731            return this.getContact().getDisplayName();
 732        }
 733    }
 734
 735    public String getAccountUuid() {
 736        return this.accountUuid;
 737    }
 738
 739    public Account getAccount() {
 740        return this.account;
 741    }
 742
 743    public void setAccount(final Account account) {
 744        this.account = account;
 745    }
 746
 747    public Contact getContact() {
 748        return this.account.getRoster().getContact(this.contactJid);
 749    }
 750
 751    @Override
 752    public Jid getJid() {
 753        return this.contactJid;
 754    }
 755
 756    public int getStatus() {
 757        return this.status;
 758    }
 759
 760    public void setStatus(int status) {
 761        this.status = status;
 762    }
 763
 764    public long getCreated() {
 765        return this.created;
 766    }
 767
 768    public ContentValues getContentValues() {
 769        ContentValues values = new ContentValues();
 770        values.put(UUID, uuid);
 771        values.put(NAME, name);
 772        values.put(CONTACT, contactUuid);
 773        values.put(ACCOUNT, accountUuid);
 774        values.put(CONTACTJID, contactJid.toString());
 775        values.put(CREATED, created);
 776        values.put(STATUS, status);
 777        values.put(MODE, mode);
 778        synchronized (this.attributes) {
 779            values.put(ATTRIBUTES, attributes.toString());
 780        }
 781        return values;
 782    }
 783
 784    public int getMode() {
 785        return this.mode;
 786    }
 787
 788    public void setMode(int mode) {
 789        this.mode = mode;
 790    }
 791
 792    /**
 793     * short for is Private and Non-anonymous
 794     */
 795    public boolean isSingleOrPrivateAndNonAnonymous() {
 796        return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
 797    }
 798
 799    public boolean isPrivateAndNonAnonymous() {
 800        return getMucOptions().isPrivateAndNonAnonymous();
 801    }
 802
 803    public synchronized MucOptions getMucOptions() {
 804        if (this.mucOptions == null) {
 805            this.mucOptions = new MucOptions(this);
 806        }
 807        return this.mucOptions;
 808    }
 809
 810    public void resetMucOptions() {
 811        this.mucOptions = null;
 812    }
 813
 814    public void setContactJid(final Jid jid) {
 815        this.contactJid = jid;
 816    }
 817
 818    public Jid getNextCounterpart() {
 819        return this.nextCounterpart;
 820    }
 821
 822    public void setNextCounterpart(Jid jid) {
 823        this.nextCounterpart = jid;
 824    }
 825
 826    public int getNextEncryption() {
 827        if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
 828            return Message.ENCRYPTION_NONE;
 829        }
 830        if (OmemoSetting.isAlways()) {
 831            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
 832        }
 833        final int defaultEncryption;
 834        if (suitableForOmemoByDefault(this)) {
 835            defaultEncryption = OmemoSetting.getEncryption();
 836        } else {
 837            defaultEncryption = Message.ENCRYPTION_NONE;
 838        }
 839        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
 840        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
 841            return defaultEncryption;
 842        } else {
 843            return encryption;
 844        }
 845    }
 846
 847    public boolean setNextEncryption(int encryption) {
 848        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
 849    }
 850
 851    public String getNextMessage() {
 852        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 853        return nextMessage == null ? "" : nextMessage;
 854    }
 855
 856    public @Nullable
 857    Draft getDraft() {
 858        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
 859        if (timestamp > getLatestMessage().getTimeSent()) {
 860            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 861            if (!TextUtils.isEmpty(message) && timestamp != 0) {
 862                return new Draft(message, timestamp);
 863            }
 864        }
 865        return null;
 866    }
 867
 868    public boolean setNextMessage(final String input) {
 869        final String message = input == null || input.trim().isEmpty() ? null : input;
 870        boolean changed = !getNextMessage().equals(message);
 871        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
 872        if (changed) {
 873            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
 874        }
 875        return changed;
 876    }
 877
 878    public Bookmark getBookmark() {
 879        return this.account.getBookmark(this.contactJid);
 880    }
 881
 882    public Message findDuplicateMessage(Message message) {
 883        synchronized (this.messages) {
 884            for (int i = this.messages.size() - 1; i >= 0; --i) {
 885                if (this.messages.get(i).similar(message)) {
 886                    return this.messages.get(i);
 887                }
 888            }
 889        }
 890        return null;
 891    }
 892
 893    public boolean hasDuplicateMessage(Message message) {
 894        return findDuplicateMessage(message) != null;
 895    }
 896
 897    public Message findSentMessageWithBody(String body) {
 898        synchronized (this.messages) {
 899            for (int i = this.messages.size() - 1; i >= 0; --i) {
 900                Message message = this.messages.get(i);
 901                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
 902                    String otherBody;
 903                    if (message.hasFileOnRemoteHost()) {
 904                        otherBody = message.getFileParams().url;
 905                    } else {
 906                        otherBody = message.body;
 907                    }
 908                    if (otherBody != null && otherBody.equals(body)) {
 909                        return message;
 910                    }
 911                }
 912            }
 913            return null;
 914        }
 915    }
 916
 917    public Message findRtpSession(final String sessionId, final int s) {
 918        synchronized (this.messages) {
 919            for (int i = this.messages.size() - 1; i >= 0; --i) {
 920                final Message message = this.messages.get(i);
 921                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
 922                    return message;
 923                }
 924            }
 925        }
 926        return null;
 927    }
 928
 929    public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
 930        if (serverMsgId == null || remoteMsgId == null) {
 931            return false;
 932        }
 933        synchronized (this.messages) {
 934            for (Message message : this.messages) {
 935                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
 936                    return true;
 937                }
 938            }
 939        }
 940        return false;
 941    }
 942
 943    public MamReference getLastMessageTransmitted() {
 944        final MamReference lastClear = getLastClearHistory();
 945        MamReference lastReceived = new MamReference(0);
 946        synchronized (this.messages) {
 947            for (int i = this.messages.size() - 1; i >= 0; --i) {
 948                final Message message = this.messages.get(i);
 949                if (message.isPrivateMessage()) {
 950                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
 951                }
 952                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
 953                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
 954                    break;
 955                }
 956            }
 957        }
 958        return MamReference.max(lastClear, lastReceived);
 959    }
 960
 961    public void setMutedTill(long value) {
 962        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
 963    }
 964
 965    public boolean isMuted() {
 966        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
 967    }
 968
 969    public boolean alwaysNotify() {
 970        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
 971    }
 972
 973    public boolean setAttribute(String key, boolean value) {
 974        return setAttribute(key, String.valueOf(value));
 975    }
 976
 977    private boolean setAttribute(String key, long value) {
 978        return setAttribute(key, Long.toString(value));
 979    }
 980
 981    private boolean setAttribute(String key, int value) {
 982        return setAttribute(key, String.valueOf(value));
 983    }
 984
 985    public boolean setAttribute(String key, String value) {
 986        synchronized (this.attributes) {
 987            try {
 988                if (value == null) {
 989                    if (this.attributes.has(key)) {
 990                        this.attributes.remove(key);
 991                        return true;
 992                    } else {
 993                        return false;
 994                    }
 995                } else {
 996                    final String prev = this.attributes.optString(key, null);
 997                    this.attributes.put(key, value);
 998                    return !value.equals(prev);
 999                }
1000            } catch (JSONException e) {
1001                throw new AssertionError(e);
1002            }
1003        }
1004    }
1005
1006    public boolean setAttribute(String key, List<Jid> jids) {
1007        JSONArray array = new JSONArray();
1008        for (Jid jid : jids) {
1009            array.put(jid.asBareJid().toString());
1010        }
1011        synchronized (this.attributes) {
1012            try {
1013                this.attributes.put(key, array);
1014                return true;
1015            } catch (JSONException e) {
1016                return false;
1017            }
1018        }
1019    }
1020
1021    public String getAttribute(String key) {
1022        synchronized (this.attributes) {
1023            return this.attributes.optString(key, null);
1024        }
1025    }
1026
1027    private List<Jid> getJidListAttribute(String key) {
1028        ArrayList<Jid> list = new ArrayList<>();
1029        synchronized (this.attributes) {
1030            try {
1031                JSONArray array = this.attributes.getJSONArray(key);
1032                for (int i = 0; i < array.length(); ++i) {
1033                    try {
1034                        list.add(Jid.of(array.getString(i)));
1035                    } catch (IllegalArgumentException e) {
1036                        //ignored
1037                    }
1038                }
1039            } catch (JSONException e) {
1040                //ignored
1041            }
1042        }
1043        return list;
1044    }
1045
1046    private int getIntAttribute(String key, int defaultValue) {
1047        String value = this.getAttribute(key);
1048        if (value == null) {
1049            return defaultValue;
1050        } else {
1051            try {
1052                return Integer.parseInt(value);
1053            } catch (NumberFormatException e) {
1054                return defaultValue;
1055            }
1056        }
1057    }
1058
1059    public long getLongAttribute(String key, long defaultValue) {
1060        String value = this.getAttribute(key);
1061        if (value == null) {
1062            return defaultValue;
1063        } else {
1064            try {
1065                return Long.parseLong(value);
1066            } catch (NumberFormatException e) {
1067                return defaultValue;
1068            }
1069        }
1070    }
1071
1072    public boolean getBooleanAttribute(String key, boolean defaultValue) {
1073        String value = this.getAttribute(key);
1074        if (value == null) {
1075            return defaultValue;
1076        } else {
1077            return Boolean.parseBoolean(value);
1078        }
1079    }
1080
1081    public void add(Message message) {
1082        synchronized (this.messages) {
1083            this.messages.add(message);
1084        }
1085    }
1086
1087    public void prepend(int offset, Message message) {
1088        synchronized (this.messages) {
1089            this.messages.add(Math.min(offset, this.messages.size()), message);
1090        }
1091    }
1092
1093    public void addAll(int index, List<Message> messages) {
1094        synchronized (this.messages) {
1095            this.messages.addAll(index, messages);
1096        }
1097        account.getPgpDecryptionService().decrypt(messages);
1098    }
1099
1100    public void expireOldMessages(long timestamp) {
1101        synchronized (this.messages) {
1102            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1103                if (iterator.next().getTimeSent() < timestamp) {
1104                    iterator.remove();
1105                }
1106            }
1107            untieMessages();
1108        }
1109    }
1110
1111    public void sort() {
1112        synchronized (this.messages) {
1113            Collections.sort(this.messages, (left, right) -> {
1114                if (left.getTimeSent() < right.getTimeSent()) {
1115                    return -1;
1116                } else if (left.getTimeSent() > right.getTimeSent()) {
1117                    return 1;
1118                } else {
1119                    return 0;
1120                }
1121            });
1122            untieMessages();
1123        }
1124    }
1125
1126    private void untieMessages() {
1127        for (Message message : this.messages) {
1128            message.untie();
1129        }
1130    }
1131
1132    public int unreadCount() {
1133        synchronized (this.messages) {
1134            int count = 0;
1135            for(final Message message : Lists.reverse(this.messages)) {
1136                if (message.isRead()) {
1137                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1138                        continue;
1139                    }
1140                    return count;
1141                }
1142                ++count;
1143            }
1144            return count;
1145        }
1146    }
1147
1148    public int receivedMessagesCount() {
1149        int count = 0;
1150        synchronized (this.messages) {
1151            for (Message message : messages) {
1152                if (message.getStatus() == Message.STATUS_RECEIVED) {
1153                    ++count;
1154                }
1155            }
1156        }
1157        return count;
1158    }
1159
1160    public int sentMessagesCount() {
1161        int count = 0;
1162        synchronized (this.messages) {
1163            for (Message message : messages) {
1164                if (message.getStatus() != Message.STATUS_RECEIVED) {
1165                    ++count;
1166                }
1167            }
1168        }
1169        return count;
1170    }
1171
1172    public boolean canInferPresence() {
1173        final Contact contact = getContact();
1174        if (contact != null && contact.canInferPresence()) return true;
1175        return sentMessagesCount() > 0;
1176    }
1177
1178    public boolean isWithStranger() {
1179        final Contact contact = getContact();
1180        return mode == MODE_SINGLE
1181                && !contact.isOwnServer()
1182                && !contact.showInContactList()
1183                && !contact.isSelf()
1184                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1185                && sentMessagesCount() == 0;
1186    }
1187
1188    public int getReceivedMessagesCountSinceUuid(String uuid) {
1189        if (uuid == null) {
1190            return 0;
1191        }
1192        int count = 0;
1193        synchronized (this.messages) {
1194            for (int i = messages.size() - 1; i >= 0; i--) {
1195                final Message message = messages.get(i);
1196                if (uuid.equals(message.getUuid())) {
1197                    return count;
1198                }
1199                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1200                    ++count;
1201                }
1202            }
1203        }
1204        return 0;
1205    }
1206
1207    @Override
1208    public int getAvatarBackgroundColor() {
1209        return UIHelper.getColorForName(getName().toString());
1210    }
1211
1212    @Override
1213    public String getAvatarName() {
1214        return getName().toString();
1215    }
1216
1217    public void setCurrentTab(int tab) {
1218        mCurrentTab = tab;
1219    }
1220
1221    public int getCurrentTab() {
1222        if (mCurrentTab >= 0) return mCurrentTab;
1223
1224        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1225            return 0;
1226        }
1227
1228        return 1;
1229    }
1230
1231    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1232        pagerAdapter.startCommand(command, xmppConnectionService);
1233    }
1234
1235    public void setupViewPager(ViewPager pager, TabLayout tabs) {
1236        pagerAdapter.setupViewPager(pager, tabs);
1237    }
1238
1239    public void showViewPager() {
1240        pagerAdapter.show();
1241    }
1242
1243    public void hideViewPager() {
1244        pagerAdapter.hide();
1245    }
1246
1247    public interface OnMessageFound {
1248        void onMessageFound(final Message message);
1249    }
1250
1251    public static class Draft {
1252        private final String message;
1253        private final long timestamp;
1254
1255        private Draft(String message, long timestamp) {
1256            this.message = message;
1257            this.timestamp = timestamp;
1258        }
1259
1260        public long getTimestamp() {
1261            return timestamp;
1262        }
1263
1264        public String getMessage() {
1265            return message;
1266        }
1267    }
1268
1269    public class ConversationPagerAdapter extends PagerAdapter {
1270        protected ViewPager mPager = null;
1271        protected TabLayout mTabs = null;
1272        ArrayList<CommandSession> sessions = null;
1273        protected View page1 = null;
1274        protected View page2 = null;
1275
1276        public void setupViewPager(ViewPager pager, TabLayout tabs) {
1277            mPager = pager;
1278            mTabs = tabs;
1279
1280            if (mPager == null) return;
1281            if (sessions != null) show();
1282
1283            page1 = pager.getChildAt(0) == null ? page1 : pager.getChildAt(0);
1284            page2 = pager.getChildAt(1) == null ? page2 : pager.getChildAt(1);
1285            pager.setAdapter(this);
1286            tabs.setupWithViewPager(mPager);
1287            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1288
1289            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1290                public void onPageScrollStateChanged(int state) { }
1291                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1292
1293                public void onPageSelected(int position) {
1294                    setCurrentTab(position);
1295                }
1296            });
1297        }
1298
1299        public void show() {
1300            if (sessions == null) {
1301                sessions = new ArrayList<>();
1302                notifyDataSetChanged();
1303            }
1304            if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1305        }
1306
1307        public void hide() {
1308            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1309            if (mPager != null) mPager.setCurrentItem(0);
1310            if (mTabs != null) mTabs.setVisibility(View.GONE);
1311            sessions = null;
1312            notifyDataSetChanged();
1313        }
1314
1315        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1316            show();
1317            CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1318
1319            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1320            packet.setTo(command.getAttributeAsJid("jid"));
1321            final Element c = packet.addChild("command", Namespace.COMMANDS);
1322            c.setAttribute("node", command.getAttribute("node"));
1323            c.setAttribute("action", "execute");
1324            View v = mPager;
1325            xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1326                v.post(() -> {
1327                    session.updateWithResponse(iq);
1328                });
1329            });
1330
1331            sessions.add(session);
1332            notifyDataSetChanged();
1333            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1334        }
1335
1336        public void removeSession(CommandSession session) {
1337            sessions.remove(session);
1338            notifyDataSetChanged();
1339        }
1340
1341        @NonNull
1342        @Override
1343        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1344            if (position == 0) {
1345                if (page1.getParent() == null) container.addView(page1);
1346                return page1;
1347            }
1348            if (position == 1) {
1349                if (page2.getParent() == null) container.addView(page2);
1350                return page2;
1351            }
1352
1353            CommandSession session = sessions.get(position-2);
1354            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1355            container.addView(binding.getRoot());
1356            session.setBinding(binding);
1357            return session;
1358        }
1359
1360        @Override
1361        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1362            if (position < 2) return;
1363
1364            container.removeView(((CommandSession) o).getView());
1365        }
1366
1367        @Override
1368        public int getItemPosition(Object o) {
1369            if (mPager != null) {
1370                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1371                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1372            }
1373
1374            int pos = sessions == null ? -1 : sessions.indexOf(o);
1375            if (pos < 0) return PagerAdapter.POSITION_NONE;
1376            return pos + 2;
1377        }
1378
1379        @Override
1380        public int getCount() {
1381            if (sessions == null) return 1;
1382
1383            int count = 2 + sessions.size();
1384            if (mTabs == null) return count;
1385
1386            if (count > 2) {
1387                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1388            } else {
1389                mTabs.setTabMode(TabLayout.MODE_FIXED);
1390            }
1391            return count;
1392        }
1393
1394        @Override
1395        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1396            if (view == o) return true;
1397
1398            if (o instanceof CommandSession) {
1399                return ((CommandSession) o).getView() == view;
1400            }
1401
1402            return false;
1403        }
1404
1405        @Nullable
1406        @Override
1407        public CharSequence getPageTitle(int position) {
1408            switch (position) {
1409                case 0:
1410                    return "Conversation";
1411                case 1:
1412                    return "Commands";
1413                default:
1414                    CommandSession session = sessions.get(position-2);
1415                    if (session == null) return super.getPageTitle(position);
1416                    return session.getTitle();
1417            }
1418        }
1419
1420        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1421            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1422                protected T binding;
1423
1424                public ViewHolder(T binding) {
1425                    super(binding.getRoot());
1426                    this.binding = binding;
1427                }
1428
1429                abstract public void bind(Item el);
1430
1431                protected void setTextOrHide(TextView v, Optional<String> s) {
1432                    if (s == null || !s.isPresent()) {
1433                        v.setVisibility(View.GONE);
1434                    } else {
1435                        v.setVisibility(View.VISIBLE);
1436                        v.setText(s.get());
1437                    }
1438                }
1439
1440                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1441                    int flags = 0;
1442                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1443                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1444
1445                    String type = field.getAttribute("type");
1446                    if (type != null) {
1447                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1448                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1449                        }
1450
1451                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1452
1453                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1454                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1455                        }
1456
1457                        if (type.equals("text-private")) {
1458                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1459                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1460                        }
1461                    }
1462
1463                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1464                    if (validate == null) return;
1465                    String datatype = validate.getAttribute("datatype");
1466                    if (datatype == null) return;
1467
1468                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1469                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1470                    }
1471
1472                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1473                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1474                    }
1475
1476                    if (datatype.equals("xs:date")) {
1477                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1478                    }
1479
1480                    if (datatype.equals("xs:dateTime")) {
1481                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1482                    }
1483
1484                    if (datatype.equals("xs:time")) {
1485                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1486                    }
1487
1488                    if (datatype.equals("xs:anyURI")) {
1489                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1490                    }
1491
1492                    if (datatype.equals("html:tel")) {
1493                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1494                    }
1495
1496                    if (datatype.equals("html:email")) {
1497                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1498                    }
1499                }
1500            }
1501
1502            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1503                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1504
1505                @Override
1506                public void bind(Item iq) {
1507                    binding.errorIcon.setVisibility(View.VISIBLE);
1508
1509                    Element error = iq.el.findChild("error");
1510                    if (error == null) return;
1511                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1512                    if (text == null || text.equals("")) {
1513                        text = error.getChildren().get(0).getName();
1514                    }
1515                    binding.message.setText(text);
1516                }
1517            }
1518
1519            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1520                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1521
1522                @Override
1523                public void bind(Item note) {
1524                    binding.message.setText(note.el.getContent());
1525
1526                    String type = note.el.getAttribute("type");
1527                    if (type != null && type.equals("error")) {
1528                        binding.errorIcon.setVisibility(View.VISIBLE);
1529                    }
1530                }
1531            }
1532
1533            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1534                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1535
1536                @Override
1537                public void bind(Item item) {
1538                    Field field = (Field) item;
1539                    setTextOrHide(binding.label, field.getLabel());
1540                    setTextOrHide(binding.desc, field.getDesc());
1541
1542                    ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1543                    for (Element el : field.el.getChildren()) {
1544                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1545                            values.add(el.getContent());
1546                        }
1547                    }
1548                    binding.values.setAdapter(values);
1549
1550                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1551                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1552                            new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1553                        });
1554                    }
1555
1556                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1557                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1558                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1559                        }
1560                        return true;
1561                    });
1562                }
1563            }
1564
1565            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1566                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1567
1568                @Override
1569                public void bind(Item item) {
1570                    Cell cell = (Cell) item;
1571
1572                    if (cell.el == null) {
1573                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1574                        setTextOrHide(binding.text, cell.reported.getLabel());
1575                    } else {
1576                        String value = cell.el.findChildContent("value", "jabber:x:data");
1577                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1578                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1579                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1580                        }
1581
1582                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1583                        binding.text.setText(text);
1584
1585                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1586                        method.setOnLinkLongClickListener((tv, url) -> {
1587                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1588                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1589                            return true;
1590                        });
1591                        binding.text.setMovementMethod(method);
1592                    }
1593                }
1594            }
1595
1596            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1597                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1598
1599                @Override
1600                public void bind(Item item) {
1601                    for (Field field : reported) {
1602                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1603                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1604                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1605                        param.width = 0;
1606                        row.getRoot().setLayoutParams(param);
1607                        binding.fields.addView(row.getRoot());
1608                        for (Element el : item.el.getChildren()) {
1609                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1610                                for (String label : field.getLabel().asSet()) {
1611                                    el.setAttribute("label", label);
1612                                }
1613                                for (String desc : field.getDesc().asSet()) {
1614                                    el.setAttribute("desc", desc);
1615                                }
1616                                for (String type : field.getType().asSet()) {
1617                                    el.setAttribute("type", type);
1618                                }
1619                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1620                                if (validate != null) el.addChild(validate);
1621                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1622                            }
1623                        }
1624                    }
1625                }
1626            }
1627
1628            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1629                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1630                    super(binding);
1631                    binding.row.setOnClickListener((v) -> {
1632                        binding.checkbox.toggle();
1633                    });
1634                    binding.checkbox.setOnCheckedChangeListener(this);
1635                }
1636                protected Element mValue = null;
1637
1638                @Override
1639                public void bind(Item item) {
1640                    Field field = (Field) item;
1641                    binding.label.setText(field.getLabel().or(""));
1642                    setTextOrHide(binding.desc, field.getDesc());
1643                    mValue = field.getValue();
1644                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1645                }
1646
1647                @Override
1648                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1649                    if (mValue == null) return;
1650
1651                    mValue.setContent(isChecked ? "true" : "false");
1652                }
1653            }
1654
1655            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1656                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1657                    super(binding);
1658                    binding.search.addTextChangedListener(this);
1659                }
1660                protected Element mValue = null;
1661                List<Option> options = new ArrayList<>();
1662                protected ArrayAdapter<Option> adapter;
1663                protected boolean open;
1664
1665                @Override
1666                public void bind(Item item) {
1667                    Field field = (Field) item;
1668                    setTextOrHide(binding.label, field.getLabel());
1669                    setTextOrHide(binding.desc, field.getDesc());
1670
1671                    if (field.error != null) {
1672                        binding.desc.setVisibility(View.VISIBLE);
1673                        binding.desc.setText(field.error);
1674                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1675                    } else {
1676                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1677                    }
1678
1679                    mValue = field.getValue();
1680
1681                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1682                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1683                    setupInputType(field.el, binding.search, null);
1684
1685                    options = field.getOptions();
1686                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1687                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1688                        if (open) binding.search.setText(mValue.getContent());
1689                    });
1690                    search("");
1691                }
1692
1693                @Override
1694                public void afterTextChanged(Editable s) {
1695                    if (open) mValue.setContent(s.toString());
1696                    search(s.toString());
1697                }
1698
1699                @Override
1700                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1701
1702                @Override
1703                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1704
1705                protected void search(String s) {
1706                    List<Option> filteredOptions;
1707                    final String q = s.replaceAll("\\W", "").toLowerCase();
1708                    if (q == null || q.equals("")) {
1709                        filteredOptions = options;
1710                    } else {
1711                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1712                    }
1713                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1714                    binding.list.setAdapter(adapter);
1715
1716                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1717                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1718                }
1719            }
1720
1721            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1722                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1723                    super(binding);
1724                    binding.open.addTextChangedListener(this);
1725                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1726                        @Override
1727                        public View getView(int position, View convertView, ViewGroup parent) {
1728                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1729                            v.setId(position);
1730                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1731                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1732                            return v;
1733                        }
1734                    };
1735                }
1736                protected Element mValue = null;
1737                protected ArrayAdapter<Option> options;
1738
1739                @Override
1740                public void bind(Item item) {
1741                    Field field = (Field) item;
1742                    setTextOrHide(binding.label, field.getLabel());
1743                    setTextOrHide(binding.desc, field.getDesc());
1744
1745                    if (field.error != null) {
1746                        binding.desc.setVisibility(View.VISIBLE);
1747                        binding.desc.setText(field.error);
1748                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1749                    } else {
1750                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1751                    }
1752
1753                    mValue = field.getValue();
1754
1755                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1756                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1757                    binding.open.setText(mValue.getContent());
1758                    setupInputType(field.el, binding.open, null);
1759
1760                    options.clear();
1761                    List<Option> theOptions = field.getOptions();
1762                    options.addAll(theOptions);
1763
1764                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1765                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1766                    float maxColumnWidth = theOptions.stream().map((x) ->
1767                        StaticLayout.getDesiredWidth(x.toString(), paint)
1768                    ).max(Float::compare).orElse(new Float(0.0));
1769                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1770                        binding.radios.setNumColumns(theOptions.size());
1771                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1772                        binding.radios.setNumColumns(theOptions.size() / 2);
1773                    } else {
1774                        binding.radios.setNumColumns(1);
1775                    }
1776                    binding.radios.setAdapter(options);
1777                }
1778
1779                @Override
1780                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1781                    if (mValue == null) return;
1782
1783                    if (isChecked) {
1784                        mValue.setContent(options.getItem(radio.getId()).getValue());
1785                        binding.open.setText(mValue.getContent());
1786                    }
1787                    options.notifyDataSetChanged();
1788                }
1789
1790                @Override
1791                public void afterTextChanged(Editable s) {
1792                    if (mValue == null) return;
1793
1794                    mValue.setContent(s.toString());
1795                    options.notifyDataSetChanged();
1796                }
1797
1798                @Override
1799                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1800
1801                @Override
1802                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1803            }
1804
1805            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1806                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1807                    super(binding);
1808                    binding.spinner.setOnItemSelectedListener(this);
1809                }
1810                protected Element mValue = null;
1811
1812                @Override
1813                public void bind(Item item) {
1814                    Field field = (Field) item;
1815                    setTextOrHide(binding.label, field.getLabel());
1816                    binding.spinner.setPrompt(field.getLabel().or(""));
1817                    setTextOrHide(binding.desc, field.getDesc());
1818
1819                    mValue = field.getValue();
1820
1821                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1822                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1823                    options.addAll(field.getOptions());
1824
1825                    binding.spinner.setAdapter(options);
1826                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1827                }
1828
1829                @Override
1830                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1831                    Option o = (Option) parent.getItemAtPosition(pos);
1832                    if (mValue == null) return;
1833
1834                    mValue.setContent(o == null ? "" : o.getValue());
1835                }
1836
1837                @Override
1838                public void onNothingSelected(AdapterView<?> parent) {
1839                    mValue.setContent("");
1840                }
1841            }
1842
1843            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1844                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1845                    super(binding);
1846                    binding.textinput.addTextChangedListener(this);
1847                }
1848                protected Element mValue = null;
1849
1850                @Override
1851                public void bind(Item item) {
1852                    Field field = (Field) item;
1853                    binding.textinputLayout.setHint(field.getLabel().or(""));
1854
1855                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1856                    for (String desc : field.getDesc().asSet()) {
1857                        binding.textinputLayout.setHelperText(desc);
1858                    }
1859
1860                    binding.textinputLayout.setErrorEnabled(field.error != null);
1861                    if (field.error != null) binding.textinputLayout.setError(field.error);
1862
1863                    mValue = field.getValue();
1864                    binding.textinput.setText(mValue.getContent());
1865                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
1866                }
1867
1868                @Override
1869                public void afterTextChanged(Editable s) {
1870                    if (mValue == null) return;
1871
1872                    mValue.setContent(s.toString());
1873                }
1874
1875                @Override
1876                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1877
1878                @Override
1879                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1880            }
1881
1882            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1883                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1884                protected String boundUrl = "";
1885
1886                @Override
1887                public void bind(Item oob) {
1888                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1889                    binding.webview.getSettings().setJavaScriptEnabled(true);
1890                    binding.webview.getSettings().setUserAgentString("Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36");
1891                    binding.webview.getSettings().setDatabaseEnabled(true);
1892                    binding.webview.getSettings().setDomStorageEnabled(true);
1893                    binding.webview.setWebChromeClient(new WebChromeClient() {
1894                        @Override
1895                        public void onProgressChanged(WebView view, int newProgress) {
1896                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1897                            binding.progressbar.setProgress(newProgress);
1898                        }
1899                    });
1900                    binding.webview.setWebViewClient(new WebViewClient() {
1901                        @Override
1902                        public void onPageFinished(WebView view, String url) {
1903                            super.onPageFinished(view, url);
1904                            mTitle = view.getTitle();
1905                            ConversationPagerAdapter.this.notifyDataSetChanged();
1906                        }
1907                    });
1908                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
1909                    if (!boundUrl.equals(url)) {
1910                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1911                        binding.webview.loadUrl(url);
1912                        boundUrl = url;
1913                    }
1914                }
1915
1916                class JsObject {
1917                    @JavascriptInterface
1918                    public void execute() { execute("execute"); }
1919
1920                    @JavascriptInterface
1921                    public void execute(String action) {
1922                        getView().post(() -> {
1923                            actionToWebview = null;
1924                            if(CommandSession.this.execute(action)) {
1925                                removeSession(CommandSession.this);
1926                            }
1927                        });
1928                    }
1929
1930                    @JavascriptInterface
1931                    public void preventDefault() {
1932                        actionToWebview = binding.webview;
1933                    }
1934                }
1935            }
1936
1937            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1938                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1939
1940                @Override
1941                public void bind(Item item) { }
1942            }
1943
1944            class Item {
1945                protected Element el;
1946                protected int viewType;
1947                protected String error = null;
1948
1949                Item(Element el, int viewType) {
1950                    this.el = el;
1951                    this.viewType = viewType;
1952                }
1953
1954                public boolean validate() {
1955                    error = null;
1956                    return true;
1957                }
1958            }
1959
1960            class Field extends Item {
1961                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
1962
1963                @Override
1964                public boolean validate() {
1965                    if (!super.validate()) return false;
1966                    if (el.findChild("required", "jabber:x:data") == null) return true;
1967                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1968
1969                    error = "this value is required";
1970                    return false;
1971                }
1972
1973                public String getVar() {
1974                    return el.getAttribute("var");
1975                }
1976
1977                public Optional<String> getType() {
1978                    return Optional.fromNullable(el.getAttribute("type"));
1979                }
1980
1981                public Optional<String> getLabel() {
1982                    String label = el.getAttribute("label");
1983                    if (label == null) label = getVar();
1984                    return Optional.fromNullable(label);
1985                }
1986
1987                public Optional<String> getDesc() {
1988                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1989                }
1990
1991                public Element getValue() {
1992                    Element value = el.findChild("value", "jabber:x:data");
1993                    if (value == null) {
1994                        value = el.addChild("value", "jabber:x:data");
1995                    }
1996                    return value;
1997                }
1998
1999                public List<Option> getOptions() {
2000                    return Option.forField(el);
2001                }
2002            }
2003
2004            class Cell extends Item {
2005                protected Field reported;
2006
2007                Cell(Field reported, Element item) {
2008                    super(item, TYPE_RESULT_CELL);
2009                    this.reported = reported;
2010                }
2011            }
2012
2013            protected Field mkField(Element el) {
2014                int viewType = -1;
2015
2016                String formType = responseElement.getAttribute("type");
2017                if (formType != null) {
2018                    String fieldType = el.getAttribute("type");
2019                    if (fieldType == null) fieldType = "text-single";
2020
2021                    if (formType.equals("result") || fieldType.equals("fixed")) {
2022                        viewType = TYPE_RESULT_FIELD;
2023                    } else if (formType.equals("form")) {
2024                        if (fieldType.equals("boolean")) {
2025                            viewType = TYPE_CHECKBOX_FIELD;
2026                        } else if (fieldType.equals("list-single")) {
2027                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2028                            if (Option.forField(el).size() > 9) {
2029                                viewType = TYPE_SEARCH_LIST_FIELD;
2030                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2031                                viewType = TYPE_RADIO_EDIT_FIELD;
2032                            } else {
2033                                viewType = TYPE_SPINNER_FIELD;
2034                            }
2035                        } else {
2036                            viewType = TYPE_TEXT_FIELD;
2037                        }
2038                    }
2039
2040                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2041                }
2042
2043                return null;
2044            }
2045
2046            protected Item mkItem(Element el, int pos) {
2047                int viewType = -1;
2048
2049                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2050                    if (el.getName().equals("note")) {
2051                        viewType = TYPE_NOTE;
2052                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2053                        viewType = TYPE_WEB;
2054                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2055                        viewType = TYPE_NOTE;
2056                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2057                        Field field = mkField(el);
2058                        if (field != null) {
2059                            items.put(pos, field);
2060                            return field;
2061                        }
2062                    }
2063                } else if (response != null) {
2064                    viewType = TYPE_ERROR;
2065                }
2066
2067                Item item = new Item(el, viewType);
2068                items.put(pos, item);
2069                return item;
2070            }
2071
2072            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2073                protected Context ctx;
2074
2075                public ActionsAdapter(Context ctx) {
2076                    super(ctx, R.layout.simple_list_item);
2077                    this.ctx = ctx;
2078                }
2079
2080                @Override
2081                public View getView(int position, View convertView, ViewGroup parent) {
2082                    View v = super.getView(position, convertView, parent);
2083                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2084                    tv.setGravity(Gravity.CENTER);
2085                    tv.setText(getItem(position).second);
2086                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2087                    if (resId != 0) tv.setText(ctx.getResources().getString(resId));
2088                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2089                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2090                    return v;
2091                }
2092
2093                public int getPosition(String s) {
2094                    for(int i = 0; i < getCount(); i++) {
2095                        if (getItem(i).first.equals(s)) return i;
2096                    }
2097                    return -1;
2098                }
2099            }
2100
2101            final int TYPE_ERROR = 1;
2102            final int TYPE_NOTE = 2;
2103            final int TYPE_WEB = 3;
2104            final int TYPE_RESULT_FIELD = 4;
2105            final int TYPE_TEXT_FIELD = 5;
2106            final int TYPE_CHECKBOX_FIELD = 6;
2107            final int TYPE_SPINNER_FIELD = 7;
2108            final int TYPE_RADIO_EDIT_FIELD = 8;
2109            final int TYPE_RESULT_CELL = 9;
2110            final int TYPE_PROGRESSBAR = 10;
2111            final int TYPE_SEARCH_LIST_FIELD = 11;
2112            final int TYPE_ITEM_CARD = 12;
2113
2114            protected boolean loading = false;
2115            protected Timer loadingTimer = new Timer();
2116            protected String mTitle;
2117            protected String mNode;
2118            protected CommandPageBinding mBinding = null;
2119            protected IqPacket response = null;
2120            protected Element responseElement = null;
2121            protected List<Field> reported = null;
2122            protected SparseArray<Item> items = new SparseArray<>();
2123            protected XmppConnectionService xmppConnectionService;
2124            protected ActionsAdapter actionsAdapter;
2125            protected GridLayoutManager layoutManager;
2126            protected WebView actionToWebview = null;
2127
2128            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2129                loading();
2130                mTitle = title;
2131                mNode = node;
2132                this.xmppConnectionService = xmppConnectionService;
2133                if (mPager != null) setupLayoutManager();
2134                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2135                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2136                    @Override
2137                    public void onChanged() {
2138                        if (mBinding == null) return;
2139
2140                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2141                    }
2142
2143                    @Override
2144                    public void onInvalidated() {}
2145                });
2146            }
2147
2148            public String getTitle() {
2149                return mTitle;
2150            }
2151
2152            public void updateWithResponse(IqPacket iq) {
2153                this.loadingTimer.cancel();
2154                this.loadingTimer = new Timer();
2155                this.loading = false;
2156                this.responseElement = null;
2157                this.reported = null;
2158                this.response = iq;
2159                this.items.clear();
2160                this.actionsAdapter.clear();
2161                layoutManager.setSpanCount(1);
2162
2163                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2164                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2165                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2166                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2167                    }
2168
2169                    for (Element el : command.getChildren()) {
2170                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2171                            for (Element action : el.getChildren()) {
2172                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2173                                if (action.getName().equals("execute")) continue;
2174
2175                                actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2176                            }
2177                        }
2178                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2179                            Data form = Data.parse(el);
2180                            String title = form.getTitle();
2181                            if (title != null) {
2182                                mTitle = title;
2183                                ConversationPagerAdapter.this.notifyDataSetChanged();
2184                            }
2185
2186                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2187                                this.responseElement = el;
2188                                setupReported(el.findChild("reported", "jabber:x:data"));
2189                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2190                            }
2191
2192                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2193                            if (actionList != null) {
2194                                actionsAdapter.clear();
2195
2196                                for (Option action : actionList.getOptions()) {
2197                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2198                                }
2199                            }
2200                            break;
2201                        }
2202                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2203                            String url = el.findChildContent("url", "jabber:x:oob");
2204                            if (url != null) {
2205                                String scheme = Uri.parse(url).getScheme();
2206                                if (scheme.equals("http") || scheme.equals("https")) {
2207                                    this.responseElement = el;
2208                                    break;
2209                                }
2210                            }
2211                        }
2212                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2213                            this.responseElement = el;
2214                            break;
2215                        }
2216                    }
2217
2218                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2219                        removeSession(this);
2220                        return;
2221                    }
2222
2223                    if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2224                        // No actions have been given, but we are not done?
2225                        // This is probably a spec violation, but we should do *something*
2226                        actionsAdapter.add(Pair.create("execute", "execute"));
2227                    }
2228
2229                    if (!actionsAdapter.isEmpty()) {
2230                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2231                            actionsAdapter.add(Pair.create("close", "close"));
2232                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2233                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2234                        }
2235                    }
2236                }
2237
2238                if (actionsAdapter.isEmpty()) {
2239                    actionsAdapter.add(Pair.create("close", "close"));
2240                }
2241
2242                notifyDataSetChanged();
2243            }
2244
2245            protected void setupReported(Element el) {
2246                if (el == null) {
2247                    reported = null;
2248                    return;
2249                }
2250
2251                reported = new ArrayList<>();
2252                for (Element fieldEl : el.getChildren()) {
2253                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2254                    reported.add(mkField(fieldEl));
2255                }
2256            }
2257
2258            @Override
2259            public int getItemCount() {
2260                if (loading) return 1;
2261                if (response == null) return 0;
2262                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2263                    int i = 0;
2264                    for (Element el : responseElement.getChildren()) {
2265                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2266                        if (el.getName().equals("title")) continue;
2267                        if (el.getName().equals("field")) {
2268                            String type = el.getAttribute("type");
2269                            if (type != null && type.equals("hidden")) continue;
2270                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2271                        }
2272
2273                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2274                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < 2) {
2275                                if (el.getName().equals("reported")) continue;
2276                                i += 1;
2277                            } else {
2278                                if (reported != null) i += reported.size();
2279                            }
2280                            continue;
2281                        }
2282
2283                        i++;
2284                    }
2285                    return i;
2286                }
2287                return 1;
2288            }
2289
2290            public Item getItem(int position) {
2291                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2292                if (items.get(position) != null) return items.get(position);
2293                if (response == null) return null;
2294
2295                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2296                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2297                        int i = 0;
2298                        for (Element el : responseElement.getChildren()) {
2299                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2300                            if (el.getName().equals("title")) continue;
2301                            if (el.getName().equals("field")) {
2302                                String type = el.getAttribute("type");
2303                                if (type != null && type.equals("hidden")) continue;
2304                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2305                            }
2306
2307                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2308                                Cell cell = null;
2309
2310                                if (reported != null) {
2311                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < 2) {
2312                                        if (el.getName().equals("reported")) continue;
2313                                        if (i == position) {
2314                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2315                                            return items.get(position);
2316                                        }
2317                                    } else {
2318                                        if (reported.size() > position - i) {
2319                                            Field reportedField = reported.get(position - i);
2320                                            Element itemField = null;
2321                                            if (el.getName().equals("item")) {
2322                                                for (Element subel : el.getChildren()) {
2323                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2324                                                       itemField = subel;
2325                                                       break;
2326                                                    }
2327                                                }
2328                                            }
2329                                            cell = new Cell(reportedField, itemField);
2330                                        } else {
2331                                            i += reported.size();
2332                                            continue;
2333                                        }
2334                                    }
2335                                }
2336
2337                                if (cell != null) {
2338                                    items.put(position, cell);
2339                                    return cell;
2340                                }
2341                            }
2342
2343                            if (i < position) {
2344                                i++;
2345                                continue;
2346                            }
2347
2348                            return mkItem(el, position);
2349                        }
2350                    }
2351                }
2352
2353                return mkItem(responseElement == null ? response : responseElement, position);
2354            }
2355
2356            @Override
2357            public int getItemViewType(int position) {
2358                return getItem(position).viewType;
2359            }
2360
2361            @Override
2362            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2363                switch(viewType) {
2364                    case TYPE_ERROR: {
2365                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2366                        return new ErrorViewHolder(binding);
2367                    }
2368                    case TYPE_NOTE: {
2369                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2370                        return new NoteViewHolder(binding);
2371                    }
2372                    case TYPE_WEB: {
2373                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2374                        return new WebViewHolder(binding);
2375                    }
2376                    case TYPE_RESULT_FIELD: {
2377                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2378                        return new ResultFieldViewHolder(binding);
2379                    }
2380                    case TYPE_RESULT_CELL: {
2381                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2382                        return new ResultCellViewHolder(binding);
2383                    }
2384                    case TYPE_ITEM_CARD: {
2385                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2386                        return new ItemCardViewHolder(binding);
2387                    }
2388                    case TYPE_CHECKBOX_FIELD: {
2389                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2390                        return new CheckboxFieldViewHolder(binding);
2391                    }
2392                    case TYPE_SEARCH_LIST_FIELD: {
2393                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2394                        return new SearchListFieldViewHolder(binding);
2395                    }
2396                    case TYPE_RADIO_EDIT_FIELD: {
2397                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2398                        return new RadioEditFieldViewHolder(binding);
2399                    }
2400                    case TYPE_SPINNER_FIELD: {
2401                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2402                        return new SpinnerFieldViewHolder(binding);
2403                    }
2404                    case TYPE_TEXT_FIELD: {
2405                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2406                        return new TextFieldViewHolder(binding);
2407                    }
2408                    case TYPE_PROGRESSBAR: {
2409                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2410                        return new ProgressBarViewHolder(binding);
2411                    }
2412                    default:
2413                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2414                }
2415            }
2416
2417            @Override
2418            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2419                viewHolder.bind(getItem(position));
2420            }
2421
2422            public View getView() {
2423                return mBinding.getRoot();
2424            }
2425
2426            public boolean validate() {
2427                int count = getItemCount();
2428                boolean isValid = true;
2429                for (int i = 0; i < count; i++) {
2430                    boolean oneIsValid = getItem(i).validate();
2431                    isValid = isValid && oneIsValid;
2432                }
2433                notifyDataSetChanged();
2434                return isValid;
2435            }
2436
2437            public boolean execute() {
2438                return execute("execute");
2439            }
2440
2441            public boolean execute(int actionPosition) {
2442                return execute(actionsAdapter.getItem(actionPosition).first);
2443            }
2444
2445            public boolean execute(String action) {
2446                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2447
2448                if (response == null) return true;
2449                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2450                if (command == null) return true;
2451                String status = command.getAttribute("status");
2452                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2453
2454                if (actionToWebview != null) {
2455                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2456                    return false;
2457                }
2458
2459                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2460                packet.setTo(response.getFrom());
2461                final Element c = packet.addChild("command", Namespace.COMMANDS);
2462                c.setAttribute("node", mNode);
2463                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2464
2465                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2466                if (!action.equals("cancel") &&
2467                    !action.equals("prev") &&
2468                    responseElement != null &&
2469                    responseElement.getName().equals("x") &&
2470                    responseElement.getNamespace().equals("jabber:x:data") &&
2471                    formType != null && formType.equals("form")) {
2472
2473                    Data form = Data.parse(responseElement);
2474                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2475                    if (actionList != null) {
2476                        actionList.setValue(action);
2477                        c.setAttribute("action", "execute");
2478                    }
2479
2480                    responseElement.setAttribute("type", "submit");
2481                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2482                    if (rsm != null) {
2483                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2484                        max.setContent("1000");
2485                        rsm.addChild(max);
2486                    }
2487
2488                    c.addChild(responseElement);
2489                }
2490
2491                if (c.getAttribute("action") == null) c.setAttribute("action", action);
2492
2493                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2494                    getView().post(() -> {
2495                        updateWithResponse(iq);
2496                    });
2497                });
2498
2499                loading();
2500                return false;
2501            }
2502
2503            protected void loading() {
2504                loadingTimer.schedule(new TimerTask() {
2505                    @Override
2506                    public void run() {
2507                        getView().post(() -> {
2508                            loading = true;
2509                            notifyDataSetChanged();
2510                        });
2511                    }
2512                }, 500);
2513            }
2514
2515            protected GridLayoutManager setupLayoutManager() {
2516                int spanCount = 1;
2517
2518                if (reported != null && mPager != null) {
2519                    float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2520                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2521                    float tableHeaderWidth = reported.stream().reduce(
2522                        0f,
2523                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2524                        (a, b) -> a + b
2525                    );
2526
2527                    spanCount = tableHeaderWidth > 0.75 * screenWidth ? 1 : this.reported.size();
2528                }
2529
2530                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2531                    items.clear();
2532                    notifyDataSetChanged();
2533                }
2534
2535                layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2536                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2537                    @Override
2538                    public int getSpanSize(int position) {
2539                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2540                        return 1;
2541                    }
2542                });
2543                return layoutManager;
2544            }
2545
2546            public void setBinding(CommandPageBinding b) {
2547                mBinding = b;
2548                // https://stackoverflow.com/a/32350474/8611
2549                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2550                    @Override
2551                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2552                        if(rv.getChildCount() > 0) {
2553                            int[] location = new int[2];
2554                            rv.getLocationOnScreen(location);
2555                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2556                            if (childView instanceof ViewGroup) {
2557                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2558                            }
2559                            if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2560                                int action = e.getAction();
2561                                switch (action) {
2562                                    case MotionEvent.ACTION_DOWN:
2563                                        rv.requestDisallowInterceptTouchEvent(true);
2564                                }
2565                            }
2566                        }
2567
2568                        return false;
2569                    }
2570
2571                    @Override
2572                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2573
2574                    @Override
2575                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2576                });
2577                mBinding.form.setLayoutManager(setupLayoutManager());
2578                mBinding.form.setAdapter(this);
2579                mBinding.actions.setAdapter(actionsAdapter);
2580                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2581                    if (execute(pos)) {
2582                        removeSession(CommandSession.this);
2583                    }
2584                });
2585
2586                actionsAdapter.notifyDataSetChanged();
2587            }
2588
2589            // https://stackoverflow.com/a/36037991/8611
2590            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2591                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2592                    View child = viewGroup.getChildAt(i);
2593                    if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2594                        View foundView = findViewAt((ViewGroup) child, x, y);
2595                        if (foundView != null && foundView.isShown()) {
2596                            return foundView;
2597                        }
2598                    } else {
2599                        int[] location = new int[2];
2600                        child.getLocationOnScreen(location);
2601                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2602                        if (rect.contains((int)x, (int)y)) {
2603                            return child;
2604                        }
2605                    }
2606                }
2607
2608                return null;
2609            }
2610        }
2611    }
2612}