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