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