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 != null && 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                                mValue.setContent(getItem(position).getValue());
2125                                execute();
2126                                loading = true;
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                            String value = dialogBinding.inputEditText.getText().toString();
2183                            mValue.setContent(value);
2184                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2185                            dialog.dismiss();
2186                            execute();
2187                            loading = true;
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                            mValue.setContent(defaultOption.getValue());
2234                            execute();
2235                            loading = true;
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                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2355                }
2356            }
2357
2358            class Item {
2359                protected Element el;
2360                protected int viewType;
2361                protected String error = null;
2362
2363                Item(Element el, int viewType) {
2364                    this.el = el;
2365                    this.viewType = viewType;
2366                }
2367
2368                public boolean validate() {
2369                    error = null;
2370                    return true;
2371                }
2372            }
2373
2374            class Field extends Item {
2375                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2376
2377                @Override
2378                public boolean validate() {
2379                    if (!super.validate()) return false;
2380                    if (el.findChild("required", "jabber:x:data") == null) return true;
2381                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2382
2383                    error = "this value is required";
2384                    return false;
2385                }
2386
2387                public String getVar() {
2388                    return el.getAttribute("var");
2389                }
2390
2391                public Optional<String> getType() {
2392                    return Optional.fromNullable(el.getAttribute("type"));
2393                }
2394
2395                public Optional<String> getLabel() {
2396                    String label = el.getAttribute("label");
2397                    if (label == null) label = getVar();
2398                    return Optional.fromNullable(label);
2399                }
2400
2401                public Optional<String> getDesc() {
2402                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2403                }
2404
2405                public Element getValue() {
2406                    Element value = el.findChild("value", "jabber:x:data");
2407                    if (value == null) {
2408                        value = el.addChild("value", "jabber:x:data");
2409                    }
2410                    return value;
2411                }
2412
2413                public void setValues(List<String> values) {
2414                    for(Element child : el.getChildren()) {
2415                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2416                            el.removeChild(child);
2417                        }
2418                    }
2419
2420                    for (String value : values) {
2421                        el.addChild("value", "jabber:x:data").setContent(value);
2422                    }
2423                }
2424
2425                public List<String> getValues() {
2426                    List<String> values = new ArrayList<>();
2427                    for(Element child : el.getChildren()) {
2428                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2429                            values.add(child.getContent());
2430                        }
2431                    }
2432                    return values;
2433                }
2434
2435                public List<Option> getOptions() {
2436                    return Option.forField(el);
2437                }
2438            }
2439
2440            class Cell extends Item {
2441                protected Field reported;
2442
2443                Cell(Field reported, Element item) {
2444                    super(item, TYPE_RESULT_CELL);
2445                    this.reported = reported;
2446                }
2447            }
2448
2449            protected Field mkField(Element el) {
2450                int viewType = -1;
2451
2452                String formType = responseElement.getAttribute("type");
2453                if (formType != null) {
2454                    String fieldType = el.getAttribute("type");
2455                    if (fieldType == null) fieldType = "text-single";
2456
2457                    if (formType.equals("result") || fieldType.equals("fixed")) {
2458                        viewType = TYPE_RESULT_FIELD;
2459                    } else if (formType.equals("form")) {
2460                        if (fieldType.equals("boolean")) {
2461                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2462                                viewType = TYPE_BUTTON_GRID_FIELD;
2463                            } else {
2464                                viewType = TYPE_CHECKBOX_FIELD;
2465                            }
2466                        } else if (fieldType.equals("list-single")) {
2467                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2468                            if (Option.forField(el).size() > 9) {
2469                                viewType = TYPE_SEARCH_LIST_FIELD;
2470                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2471                                viewType = TYPE_BUTTON_GRID_FIELD;
2472                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2473                                viewType = TYPE_RADIO_EDIT_FIELD;
2474                            } else {
2475                                viewType = TYPE_SPINNER_FIELD;
2476                            }
2477                        } else {
2478                            viewType = TYPE_TEXT_FIELD;
2479                        }
2480                    }
2481
2482                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2483                }
2484
2485                return null;
2486            }
2487
2488            protected Item mkItem(Element el, int pos) {
2489                int viewType = -1;
2490
2491                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2492                    if (el.getName().equals("note")) {
2493                        viewType = TYPE_NOTE;
2494                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2495                        viewType = TYPE_WEB;
2496                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2497                        viewType = TYPE_NOTE;
2498                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2499                        Field field = mkField(el);
2500                        if (field != null) {
2501                            items.put(pos, field);
2502                            return field;
2503                        }
2504                    }
2505                } else if (response != null) {
2506                    viewType = TYPE_ERROR;
2507                }
2508
2509                Item item = new Item(el, viewType);
2510                items.put(pos, item);
2511                return item;
2512            }
2513
2514            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2515                protected Context ctx;
2516
2517                public ActionsAdapter(Context ctx) {
2518                    super(ctx, R.layout.simple_list_item);
2519                    this.ctx = ctx;
2520                }
2521
2522                @Override
2523                public View getView(int position, View convertView, ViewGroup parent) {
2524                    View v = super.getView(position, convertView, parent);
2525                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2526                    tv.setGravity(Gravity.CENTER);
2527                    tv.setText(getItem(position).second);
2528                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2529                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2530                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2531                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2532                    return v;
2533                }
2534
2535                public int getPosition(String s) {
2536                    for(int i = 0; i < getCount(); i++) {
2537                        if (getItem(i).first.equals(s)) return i;
2538                    }
2539                    return -1;
2540                }
2541
2542                public int countExceptCancel() {
2543                    int count = 0;
2544                    for(int i = 0; i < getCount(); i++) {
2545                        if (!getItem(i).first.equals("cancel")) count++;
2546                    }
2547                    return count;
2548                }
2549
2550                public void clearExceptCancel() {
2551                    Pair<String,String> cancelItem = null;
2552                    for(int i = 0; i < getCount(); i++) {
2553                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2554                    }
2555                    clear();
2556                    if (cancelItem != null) add(cancelItem);
2557                }
2558            }
2559
2560            final int TYPE_ERROR = 1;
2561            final int TYPE_NOTE = 2;
2562            final int TYPE_WEB = 3;
2563            final int TYPE_RESULT_FIELD = 4;
2564            final int TYPE_TEXT_FIELD = 5;
2565            final int TYPE_CHECKBOX_FIELD = 6;
2566            final int TYPE_SPINNER_FIELD = 7;
2567            final int TYPE_RADIO_EDIT_FIELD = 8;
2568            final int TYPE_RESULT_CELL = 9;
2569            final int TYPE_PROGRESSBAR = 10;
2570            final int TYPE_SEARCH_LIST_FIELD = 11;
2571            final int TYPE_ITEM_CARD = 12;
2572            final int TYPE_BUTTON_GRID_FIELD = 13;
2573
2574            protected boolean executing = false;
2575            protected boolean loading = false;
2576            protected boolean loadingHasBeenLong = false;
2577            protected Timer loadingTimer = new Timer();
2578            protected String mTitle;
2579            protected String mNode;
2580            protected CommandPageBinding mBinding = null;
2581            protected IqPacket response = null;
2582            protected Element responseElement = null;
2583            protected List<Field> reported = null;
2584            protected SparseArray<Item> items = new SparseArray<>();
2585            protected XmppConnectionService xmppConnectionService;
2586            protected ActionsAdapter actionsAdapter;
2587            protected GridLayoutManager layoutManager;
2588            protected WebView actionToWebview = null;
2589            protected int fillableFieldCount = 0;
2590            protected IqPacket pendingResponsePacket = null;
2591
2592            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2593                loading();
2594                mTitle = title;
2595                mNode = node;
2596                this.xmppConnectionService = xmppConnectionService;
2597                if (mPager != null) setupLayoutManager();
2598                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2599                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2600                    @Override
2601                    public void onChanged() {
2602                        if (mBinding == null) return;
2603
2604                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2605                    }
2606
2607                    @Override
2608                    public void onInvalidated() {}
2609                });
2610            }
2611
2612            public String getTitle() {
2613                return mTitle;
2614            }
2615
2616            public String getNode() {
2617                return mNode;
2618            }
2619
2620            public void updateWithResponse(final IqPacket iq) {
2621                if (getView() != null && getView().isAttachedToWindow()) {
2622                    getView().post(() -> updateWithResponseUiThread(iq));
2623                } else {
2624                    pendingResponsePacket = iq;
2625                }
2626            }
2627
2628            protected void updateWithResponseUiThread(final IqPacket iq) {
2629                this.loadingTimer.cancel();
2630                this.loadingTimer = new Timer();
2631                this.executing = false;
2632                this.loading = false;
2633                this.loadingHasBeenLong = false;
2634                this.responseElement = null;
2635                this.fillableFieldCount = 0;
2636                this.reported = null;
2637                this.response = iq;
2638                this.items.clear();
2639                this.actionsAdapter.clear();
2640                layoutManager.setSpanCount(1);
2641
2642                boolean actionsCleared = false;
2643                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2644                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2645                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2646                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2647                    }
2648
2649                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2650                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2651                    }
2652
2653                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2654                    if (actions != null) {
2655                        for (Element action : actions.getChildren()) {
2656                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2657                            if ("execute".equals(action.getName())) continue;
2658
2659                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2660                        }
2661                    }
2662
2663                    for (Element el : command.getChildren()) {
2664                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2665                            Data form = Data.parse(el);
2666                            String title = form.getTitle();
2667                            if (title != null) {
2668                                mTitle = title;
2669                                ConversationPagerAdapter.this.notifyDataSetChanged();
2670                            }
2671
2672                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2673                                this.responseElement = el;
2674                                setupReported(el.findChild("reported", "jabber:x:data"));
2675                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2676                            }
2677
2678                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2679                            if (actionList != null) {
2680                                actionsAdapter.clear();
2681
2682                                for (Option action : actionList.getOptions()) {
2683                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2684                                }
2685                            }
2686
2687                            String fillableFieldType = null;
2688                            String fillableFieldValue = null;
2689                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2690                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2691                                    fillableFieldType = field.getType();
2692                                    fillableFieldValue = field.getValue();
2693                                    fillableFieldCount++;
2694                                }
2695                            }
2696
2697                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2698                                actionsCleared = true;
2699                                actionsAdapter.clearExceptCancel();
2700                            }
2701                            break;
2702                        }
2703                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2704                            String url = el.findChildContent("url", "jabber:x:oob");
2705                            if (url != null) {
2706                                String scheme = Uri.parse(url).getScheme();
2707                                if (scheme.equals("http") || scheme.equals("https")) {
2708                                    this.responseElement = el;
2709                                    break;
2710                                }
2711                                if (scheme.equals("xmpp")) {
2712                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2713                                    intent.setAction(Intent.ACTION_VIEW);
2714                                    intent.setData(Uri.parse(url));
2715                                    getView().getContext().startActivity(intent);
2716                                    break;
2717                                }
2718                            }
2719                        }
2720                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2721                            this.responseElement = el;
2722                            break;
2723                        }
2724                    }
2725
2726                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2727                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2728                            if (xmppConnectionService.isOnboarding()) {
2729                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2730                                    xmppConnectionService.deleteAccount(getAccount());
2731                                } else {
2732                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2733                                        removeSession(this);
2734                                        return;
2735                                    } else {
2736                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2737                                        xmppConnectionService.deleteAccount(getAccount());
2738                                    }
2739                                }
2740                            }
2741                            xmppConnectionService.archiveConversation(Conversation.this);
2742                        }
2743
2744                        removeSession(this);
2745                        return;
2746                    }
2747
2748                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2749                        // No actions have been given, but we are not done?
2750                        // This is probably a spec violation, but we should do *something*
2751                        actionsAdapter.add(Pair.create("execute", "execute"));
2752                    }
2753
2754                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2755                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2756                            actionsAdapter.add(Pair.create("close", "close"));
2757                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2758                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2759                        }
2760                    }
2761                }
2762
2763                if (actionsAdapter.isEmpty()) {
2764                    actionsAdapter.add(Pair.create("close", "close"));
2765                }
2766
2767                actionsAdapter.sort((x, y) -> {
2768                    if (x.first.equals("cancel")) return -1;
2769                    if (y.first.equals("cancel")) return 1;
2770                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2771                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2772                    return 0;
2773                });
2774
2775                Data dataForm = null;
2776                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2777                if (mNode.equals("jabber:iq:register") &&
2778                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
2779                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2780
2781
2782                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2783                    execute();
2784                }
2785                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2786                notifyDataSetChanged();
2787            }
2788
2789            protected void setupReported(Element el) {
2790                if (el == null) {
2791                    reported = null;
2792                    return;
2793                }
2794
2795                reported = new ArrayList<>();
2796                for (Element fieldEl : el.getChildren()) {
2797                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2798                    reported.add(mkField(fieldEl));
2799                }
2800            }
2801
2802            @Override
2803            public int getItemCount() {
2804                if (loading) return 1;
2805                if (response == null) return 0;
2806                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2807                    int i = 0;
2808                    for (Element el : responseElement.getChildren()) {
2809                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2810                        if (el.getName().equals("title")) continue;
2811                        if (el.getName().equals("field")) {
2812                            String type = el.getAttribute("type");
2813                            if (type != null && type.equals("hidden")) continue;
2814                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2815                        }
2816
2817                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2818                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2819                                if (el.getName().equals("reported")) continue;
2820                                i += 1;
2821                            } else {
2822                                if (reported != null) i += reported.size();
2823                            }
2824                            continue;
2825                        }
2826
2827                        i++;
2828                    }
2829                    return i;
2830                }
2831                return 1;
2832            }
2833
2834            public Item getItem(int position) {
2835                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2836                if (items.get(position) != null) return items.get(position);
2837                if (response == null) return null;
2838
2839                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2840                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2841                        int i = 0;
2842                        for (Element el : responseElement.getChildren()) {
2843                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2844                            if (el.getName().equals("title")) continue;
2845                            if (el.getName().equals("field")) {
2846                                String type = el.getAttribute("type");
2847                                if (type != null && type.equals("hidden")) continue;
2848                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2849                            }
2850
2851                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2852                                Cell cell = null;
2853
2854                                if (reported != null) {
2855                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2856                                        if (el.getName().equals("reported")) continue;
2857                                        if (i == position) {
2858                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2859                                            return items.get(position);
2860                                        }
2861                                    } else {
2862                                        if (reported.size() > position - i) {
2863                                            Field reportedField = reported.get(position - i);
2864                                            Element itemField = null;
2865                                            if (el.getName().equals("item")) {
2866                                                for (Element subel : el.getChildren()) {
2867                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2868                                                       itemField = subel;
2869                                                       break;
2870                                                    }
2871                                                }
2872                                            }
2873                                            cell = new Cell(reportedField, itemField);
2874                                        } else {
2875                                            i += reported.size();
2876                                            continue;
2877                                        }
2878                                    }
2879                                }
2880
2881                                if (cell != null) {
2882                                    items.put(position, cell);
2883                                    return cell;
2884                                }
2885                            }
2886
2887                            if (i < position) {
2888                                i++;
2889                                continue;
2890                            }
2891
2892                            return mkItem(el, position);
2893                        }
2894                    }
2895                }
2896
2897                return mkItem(responseElement == null ? response : responseElement, position);
2898            }
2899
2900            @Override
2901            public int getItemViewType(int position) {
2902                return getItem(position).viewType;
2903            }
2904
2905            @Override
2906            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2907                switch(viewType) {
2908                    case TYPE_ERROR: {
2909                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2910                        return new ErrorViewHolder(binding);
2911                    }
2912                    case TYPE_NOTE: {
2913                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2914                        return new NoteViewHolder(binding);
2915                    }
2916                    case TYPE_WEB: {
2917                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2918                        return new WebViewHolder(binding);
2919                    }
2920                    case TYPE_RESULT_FIELD: {
2921                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2922                        return new ResultFieldViewHolder(binding);
2923                    }
2924                    case TYPE_RESULT_CELL: {
2925                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2926                        return new ResultCellViewHolder(binding);
2927                    }
2928                    case TYPE_ITEM_CARD: {
2929                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2930                        return new ItemCardViewHolder(binding);
2931                    }
2932                    case TYPE_CHECKBOX_FIELD: {
2933                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2934                        return new CheckboxFieldViewHolder(binding);
2935                    }
2936                    case TYPE_SEARCH_LIST_FIELD: {
2937                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2938                        return new SearchListFieldViewHolder(binding);
2939                    }
2940                    case TYPE_RADIO_EDIT_FIELD: {
2941                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2942                        return new RadioEditFieldViewHolder(binding);
2943                    }
2944                    case TYPE_SPINNER_FIELD: {
2945                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2946                        return new SpinnerFieldViewHolder(binding);
2947                    }
2948                    case TYPE_BUTTON_GRID_FIELD: {
2949                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2950                        return new ButtonGridFieldViewHolder(binding);
2951                    }
2952                    case TYPE_TEXT_FIELD: {
2953                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2954                        return new TextFieldViewHolder(binding);
2955                    }
2956                    case TYPE_PROGRESSBAR: {
2957                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2958                        return new ProgressBarViewHolder(binding);
2959                    }
2960                    default:
2961                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response);
2962                }
2963            }
2964
2965            @Override
2966            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2967                viewHolder.bind(getItem(position));
2968            }
2969
2970            public View getView() {
2971                if (mBinding == null) return null;
2972                return mBinding.getRoot();
2973            }
2974
2975            public boolean validate() {
2976                int count = getItemCount();
2977                boolean isValid = true;
2978                for (int i = 0; i < count; i++) {
2979                    boolean oneIsValid = getItem(i).validate();
2980                    isValid = isValid && oneIsValid;
2981                }
2982                notifyDataSetChanged();
2983                return isValid;
2984            }
2985
2986            public boolean execute() {
2987                return execute("execute");
2988            }
2989
2990            public boolean execute(int actionPosition) {
2991                return execute(actionsAdapter.getItem(actionPosition).first);
2992            }
2993
2994            public synchronized boolean execute(String action) {
2995                if (!"cancel".equals(action) && executing) {
2996                    loadingHasBeenLong = true;
2997                    notifyDataSetChanged();
2998                    return false;
2999                }
3000                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3001
3002                if (response == null) return true;
3003                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3004                if (command == null) return true;
3005                String status = command.getAttribute("status");
3006                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3007
3008                if (actionToWebview != null && !action.equals("cancel")) {
3009                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3010                    return false;
3011                }
3012
3013                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3014                packet.setTo(response.getFrom());
3015                final Element c = packet.addChild("command", Namespace.COMMANDS);
3016                c.setAttribute("node", mNode);
3017                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3018
3019                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3020                if (!action.equals("cancel") &&
3021                    !action.equals("prev") &&
3022                    responseElement != null &&
3023                    responseElement.getName().equals("x") &&
3024                    responseElement.getNamespace().equals("jabber:x:data") &&
3025                    formType != null && formType.equals("form")) {
3026
3027                    Data form = Data.parse(responseElement);
3028                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3029                    if (actionList != null) {
3030                        actionList.setValue(action);
3031                        c.setAttribute("action", "execute");
3032                    }
3033
3034                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3035                        if (form.getValue("gateway-jid") == null) {
3036                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3037                        } else {
3038                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3039                        }
3040                    }
3041
3042                    responseElement.setAttribute("type", "submit");
3043                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3044                    if (rsm != null) {
3045                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3046                        max.setContent("1000");
3047                        rsm.addChild(max);
3048                    }
3049
3050                    c.addChild(responseElement);
3051                }
3052
3053                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3054
3055                executing = true;
3056                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3057                    updateWithResponse(iq);
3058                });
3059
3060                loading();
3061                return false;
3062            }
3063
3064            public void refresh() {
3065                notifyDataSetChanged();
3066            }
3067
3068            protected void loading() {
3069                View v = getView();
3070                loadingTimer.schedule(new TimerTask() {
3071                    @Override
3072                    public void run() {
3073                        View v2 = getView();
3074                        loading = true;
3075
3076                        loadingTimer.schedule(new TimerTask() {
3077                            @Override
3078                            public void run() {
3079                                loadingHasBeenLong = true;
3080                                if (v == null && v2 == null) return;
3081                                (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3082                            }
3083                        }, 3000);
3084
3085                        if (v == null && v2 == null) return;
3086                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3087                    }
3088                }, 500);
3089            }
3090
3091            protected GridLayoutManager setupLayoutManager() {
3092                int spanCount = 1;
3093
3094                Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3095                if (reported != null) {
3096                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3097                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3098                    float tableHeaderWidth = reported.stream().reduce(
3099                        0f,
3100                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3101                        (a, b) -> a + b
3102                    );
3103
3104                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3105                }
3106
3107                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3108                    items.clear();
3109                    notifyDataSetChanged();
3110                }
3111
3112                layoutManager = new GridLayoutManager(ctx, spanCount);
3113                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3114                    @Override
3115                    public int getSpanSize(int position) {
3116                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3117                        return 1;
3118                    }
3119                });
3120                return layoutManager;
3121            }
3122
3123            protected void setBinding(CommandPageBinding b) {
3124                mBinding = b;
3125                // https://stackoverflow.com/a/32350474/8611
3126                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3127                    @Override
3128                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3129                        if(rv.getChildCount() > 0) {
3130                            int[] location = new int[2];
3131                            rv.getLocationOnScreen(location);
3132                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3133                            if (childView instanceof ViewGroup) {
3134                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3135                            }
3136                            int action = e.getAction();
3137                            switch (action) {
3138                                case MotionEvent.ACTION_DOWN:
3139                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3140                                        rv.requestDisallowInterceptTouchEvent(true);
3141                                    }
3142                                case MotionEvent.ACTION_UP:
3143                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3144                                        rv.requestDisallowInterceptTouchEvent(true);
3145                                    }
3146                            }
3147                        }
3148
3149                        return false;
3150                    }
3151
3152                    @Override
3153                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3154
3155                    @Override
3156                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3157                });
3158                mBinding.form.setLayoutManager(setupLayoutManager());
3159                mBinding.form.setAdapter(this);
3160                mBinding.actions.setAdapter(actionsAdapter);
3161                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3162                    if (execute(pos)) {
3163                        removeSession(CommandSession.this);
3164                    }
3165                });
3166
3167                actionsAdapter.notifyDataSetChanged();
3168
3169                if (pendingResponsePacket != null) {
3170                    final IqPacket pending = pendingResponsePacket;
3171                    pendingResponsePacket = null;
3172                    updateWithResponseUiThread(pending);
3173                }
3174            }
3175
3176            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3177                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3178                setBinding(binding);
3179                return binding.getRoot();
3180            }
3181
3182            // https://stackoverflow.com/a/36037991/8611
3183            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3184                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3185                    View child = viewGroup.getChildAt(i);
3186                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3187                        View foundView = findViewAt((ViewGroup) child, x, y);
3188                        if (foundView != null && foundView.isShown()) {
3189                            return foundView;
3190                        }
3191                    } else {
3192                        int[] location = new int[2];
3193                        child.getLocationOnScreen(location);
3194                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3195                        if (rect.contains((int)x, (int)y)) {
3196                            return child;
3197                        }
3198                    }
3199                }
3200
3201                return null;
3202            }
3203        }
3204    }
3205}