Conversation.java

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