Conversation.java

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