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 (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1396            if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1397            if (page1 == null || page2 == null) {
1398                throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1399            }
1400            pager.removeView(page1);
1401            pager.removeView(page2);
1402            pager.setAdapter(this);
1403            tabs.setupWithViewPager(mPager);
1404            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1405
1406            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1407                public void onPageScrollStateChanged(int state) { }
1408                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1409
1410                public void onPageSelected(int position) {
1411                    setCurrentTab(position);
1412                }
1413            });
1414        }
1415
1416        public void show() {
1417            if (sessions == null) {
1418                sessions = new ArrayList<>();
1419                notifyDataSetChanged();
1420            }
1421            if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1422        }
1423
1424        public void hide() {
1425            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1426            if (mPager != null) mPager.setCurrentItem(0);
1427            if (mTabs != null) mTabs.setVisibility(View.GONE);
1428            sessions = null;
1429            notifyDataSetChanged();
1430        }
1431
1432        public void refreshSessions() {
1433            if (sessions == null) return;
1434
1435            for (ConversationPage session : sessions) {
1436                session.refresh();
1437            }
1438        }
1439
1440        public void startWebxdc(WebxdcPage page) {
1441            show();
1442            sessions.add(page);
1443            notifyDataSetChanged();
1444            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1445        }
1446
1447        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1448            show();
1449            CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1450
1451            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1452            packet.setTo(command.getAttributeAsJid("jid"));
1453            final Element c = packet.addChild("command", Namespace.COMMANDS);
1454            c.setAttribute("node", command.getAttribute("node"));
1455            c.setAttribute("action", "execute");
1456
1457            final TimerTask task = new TimerTask() {
1458                @Override
1459                public void run() {
1460                    if (getAccount().getStatus() != Account.State.ONLINE) {
1461                        final TimerTask self = this;
1462                        new Timer().schedule(new TimerTask() {
1463                            @Override
1464                            public void run() {
1465                                self.run();
1466                            }
1467                        }, 1000);
1468                    } else {
1469                        xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1470                            session.updateWithResponse(iq);
1471                        });
1472                    }
1473                }
1474            };
1475
1476            if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1477                new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1478                    if (signedData != null && signature != null) {
1479                        c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1480                        c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1481                    }
1482
1483                    task.run();
1484                }).checkLicense();
1485            } else {
1486                task.run();
1487            }
1488
1489            sessions.add(session);
1490            notifyDataSetChanged();
1491            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1492        }
1493
1494        public void removeSession(ConversationPage session) {
1495            sessions.remove(session);
1496            notifyDataSetChanged();
1497            if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1498        }
1499
1500        public boolean switchToSession(final String node) {
1501            if (sessions == null) return false;
1502
1503            int i = 0;
1504            for (ConversationPage session : sessions) {
1505                if (session.getNode().equals(node)) {
1506                    if (mPager != null) mPager.setCurrentItem(i + 2);
1507                    return true;
1508                }
1509                i++;
1510            }
1511
1512            return false;
1513        }
1514
1515        @NonNull
1516        @Override
1517        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1518            if (position == 0) {
1519                if (page1 != null && page1.getParent() != null) {
1520                    ((ViewGroup) page1.getParent()).removeView(page1);
1521                }
1522                container.addView(page1);
1523                return page1;
1524            }
1525            if (position == 1) {
1526                if (page2 != null && page2.getParent() != null) {
1527                    ((ViewGroup) page2.getParent()).removeView(page2);
1528                }
1529                container.addView(page2);
1530                return page2;
1531            }
1532
1533            ConversationPage session = sessions.get(position-2);
1534            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1535            if (v != null && v.getParent() != null) {
1536                ((ViewGroup) v.getParent()).removeView(v);
1537            }
1538            container.addView(v);
1539            return session;
1540        }
1541
1542        @Override
1543        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1544            if (position < 2) {
1545                container.removeView((View) o);
1546                return;
1547            }
1548
1549            container.removeView(((ConversationPage) o).getView());
1550        }
1551
1552        @Override
1553        public int getItemPosition(Object o) {
1554            if (mPager != null) {
1555                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1556                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1557            }
1558
1559            int pos = sessions == null ? -1 : sessions.indexOf(o);
1560            if (pos < 0) return PagerAdapter.POSITION_NONE;
1561            return pos + 2;
1562        }
1563
1564        @Override
1565        public int getCount() {
1566            if (sessions == null) return 1;
1567
1568            int count = 2 + sessions.size();
1569            if (mTabs == null) return count;
1570
1571            if (count > 2) {
1572                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1573            } else {
1574                mTabs.setTabMode(TabLayout.MODE_FIXED);
1575            }
1576            return count;
1577        }
1578
1579        @Override
1580        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1581            if (view == o) return true;
1582
1583            if (o instanceof ConversationPage) {
1584                return ((ConversationPage) o).getView() == view;
1585            }
1586
1587            return false;
1588        }
1589
1590        @Nullable
1591        @Override
1592        public CharSequence getPageTitle(int position) {
1593            switch (position) {
1594                case 0:
1595                    return "Conversation";
1596                case 1:
1597                    return "Commands";
1598                default:
1599                    ConversationPage session = sessions.get(position-2);
1600                    if (session == null) return super.getPageTitle(position);
1601                    return session.getTitle();
1602            }
1603        }
1604
1605        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1606            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1607                protected T binding;
1608
1609                public ViewHolder(T binding) {
1610                    super(binding.getRoot());
1611                    this.binding = binding;
1612                }
1613
1614                abstract public void bind(Item el);
1615
1616                protected void setTextOrHide(TextView v, Optional<String> s) {
1617                    if (s == null || !s.isPresent()) {
1618                        v.setVisibility(View.GONE);
1619                    } else {
1620                        v.setVisibility(View.VISIBLE);
1621                        v.setText(s.get());
1622                    }
1623                }
1624
1625                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1626                    int flags = 0;
1627                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1628                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1629
1630                    String type = field.getAttribute("type");
1631                    if (type != null) {
1632                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1633                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1634                        }
1635
1636                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1637
1638                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1639                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1640                        }
1641
1642                        if (type.equals("text-private")) {
1643                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1644                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1645                        }
1646                    }
1647
1648                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1649                    if (validate == null) return;
1650                    String datatype = validate.getAttribute("datatype");
1651                    if (datatype == null) return;
1652
1653                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1654                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1655                    }
1656
1657                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1658                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1659                    }
1660
1661                    if (datatype.equals("xs:date")) {
1662                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1663                    }
1664
1665                    if (datatype.equals("xs:dateTime")) {
1666                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1667                    }
1668
1669                    if (datatype.equals("xs:time")) {
1670                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1671                    }
1672
1673                    if (datatype.equals("xs:anyURI")) {
1674                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1675                    }
1676
1677                    if (datatype.equals("html:tel")) {
1678                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1679                    }
1680
1681                    if (datatype.equals("html:email")) {
1682                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1683                    }
1684                }
1685
1686                protected String formatValue(String datatype, String value, boolean compact) {
1687                    if ("xs:dateTime".equals(datatype)) {
1688                        ZonedDateTime zonedDateTime = null;
1689                        try {
1690                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1691                        } catch (final DateTimeParseException e) {
1692                            try {
1693                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1694                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
1695                            } catch (final DateTimeParseException e2) { }
1696                        }
1697                        if (zonedDateTime == null) return value;
1698                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1699                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1700                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
1701                    }
1702
1703                    if ("html:tel".equals(datatype) && !compact) {
1704                        return PhoneNumberUtils.formatNumber(value, value, null);
1705                    }
1706
1707                    return value;
1708                }
1709            }
1710
1711            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1712                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1713
1714                @Override
1715                public void bind(Item iq) {
1716                    binding.errorIcon.setVisibility(View.VISIBLE);
1717
1718                    Element error = iq.el.findChild("error");
1719                    if (error == null) return;
1720                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1721                    if (text == null || text.equals("")) {
1722                        text = error.getChildren().get(0).getName();
1723                    }
1724                    binding.message.setText(text);
1725                }
1726            }
1727
1728            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1729                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1730
1731                @Override
1732                public void bind(Item note) {
1733                    binding.message.setText(note.el.getContent());
1734
1735                    String type = note.el.getAttribute("type");
1736                    if (type != null && type.equals("error")) {
1737                        binding.errorIcon.setVisibility(View.VISIBLE);
1738                    }
1739                }
1740            }
1741
1742            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1743                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1744
1745                @Override
1746                public void bind(Item item) {
1747                    Field field = (Field) item;
1748                    setTextOrHide(binding.label, field.getLabel());
1749                    setTextOrHide(binding.desc, field.getDesc());
1750
1751                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
1752                    if (media == null) {
1753                        binding.mediaImage.setVisibility(View.GONE);
1754                    } else {
1755                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1756                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1757                        for (Element uriEl : media.getChildren()) {
1758                            if (!"uri".equals(uriEl.getName())) continue;
1759                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1760                            String mimeType = uriEl.getAttribute("type");
1761                            String uriS = uriEl.getContent();
1762                            if (mimeType == null || uriS == null) continue;
1763                            Uri uri = Uri.parse(uriS);
1764                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1765                                final Drawable d = cache.get(uri.toString());
1766                                if (d == null) {
1767                                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
1768                                    Message dummy = new Message(Conversation.this, uri.toString(), Message.ENCRYPTION_NONE);
1769                                    dummy.setFileParams(new Message.FileParams(uri.toString()));
1770                                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
1771                                        if (file == null) {
1772                                            dummy.getTransferable().start();
1773                                        } else {
1774                                            try {
1775                                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, uri.toString());
1776                                            } catch (final Exception e) { }
1777                                        }
1778                                    });
1779                                } else {
1780                                    binding.mediaImage.setImageDrawable(d);
1781                                    binding.mediaImage.setVisibility(View.VISIBLE);
1782                                }
1783                            }
1784                        }
1785                    }
1786
1787                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1788                    String datatype = validate == null ? null : validate.getAttribute("datatype");
1789
1790                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1791                    for (Element el : field.el.getChildren()) {
1792                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1793                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1794                        }
1795                    }
1796                    binding.values.setAdapter(values);
1797                    Util.justifyListViewHeightBasedOnChildren(binding.values);
1798
1799                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1800                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1801                            new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString()).onClick(binding.values);
1802                        });
1803                    } else if ("xs:anyURI".equals(datatype)) {
1804                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1805                            new FixedURLSpan(values.getItem(pos).getValue()).onClick(binding.values);
1806                        });
1807                    } else if ("html:tel".equals(datatype)) {
1808                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1809                            try {
1810                                new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue())).onClick(binding.values);
1811                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1812                        });
1813                    }
1814
1815                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1816                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1817                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1818                        }
1819                        return true;
1820                    });
1821                }
1822            }
1823
1824            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1825                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1826
1827                @Override
1828                public void bind(Item item) {
1829                    Cell cell = (Cell) item;
1830
1831                    if (cell.el == null) {
1832                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1833                        setTextOrHide(binding.text, cell.reported.getLabel());
1834                    } else {
1835                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1836                        String datatype = validate == null ? null : validate.getAttribute("datatype");
1837                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1838                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1839                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1840                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1841                        }
1842
1843                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1844                        binding.text.setText(text);
1845
1846                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1847                        method.setOnLinkLongClickListener((tv, url) -> {
1848                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1849                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1850                            return true;
1851                        });
1852                        binding.text.setMovementMethod(method);
1853                    }
1854                }
1855            }
1856
1857            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1858                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1859
1860                @Override
1861                public void bind(Item item) {
1862                    binding.fields.removeAllViews();
1863
1864                    for (Field field : reported) {
1865                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1866                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1867                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1868                        param.width = 0;
1869                        row.getRoot().setLayoutParams(param);
1870                        binding.fields.addView(row.getRoot());
1871                        for (Element el : item.el.getChildren()) {
1872                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1873                                for (String label : field.getLabel().asSet()) {
1874                                    el.setAttribute("label", label);
1875                                }
1876                                for (String desc : field.getDesc().asSet()) {
1877                                    el.setAttribute("desc", desc);
1878                                }
1879                                for (String type : field.getType().asSet()) {
1880                                    el.setAttribute("type", type);
1881                                }
1882                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1883                                if (validate != null) el.addChild(validate);
1884                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1885                            }
1886                        }
1887                    }
1888                }
1889            }
1890
1891            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1892                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1893                    super(binding);
1894                    binding.row.setOnClickListener((v) -> {
1895                        binding.checkbox.toggle();
1896                    });
1897                    binding.checkbox.setOnCheckedChangeListener(this);
1898                }
1899                protected Element mValue = null;
1900
1901                @Override
1902                public void bind(Item item) {
1903                    Field field = (Field) item;
1904                    binding.label.setText(field.getLabel().or(""));
1905                    setTextOrHide(binding.desc, field.getDesc());
1906                    mValue = field.getValue();
1907                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1908                }
1909
1910                @Override
1911                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1912                    if (mValue == null) return;
1913
1914                    mValue.setContent(isChecked ? "true" : "false");
1915                }
1916            }
1917
1918            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1919                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1920                    super(binding);
1921                    binding.search.addTextChangedListener(this);
1922                }
1923                protected Element mValue = null;
1924                List<Option> options = new ArrayList<>();
1925                protected ArrayAdapter<Option> adapter;
1926                protected boolean open;
1927
1928                @Override
1929                public void bind(Item item) {
1930                    Field field = (Field) item;
1931                    setTextOrHide(binding.label, field.getLabel());
1932                    setTextOrHide(binding.desc, field.getDesc());
1933
1934                    if (field.error != null) {
1935                        binding.desc.setVisibility(View.VISIBLE);
1936                        binding.desc.setText(field.error);
1937                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1938                    } else {
1939                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1940                    }
1941
1942                    mValue = field.getValue();
1943
1944                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1945                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1946                    setupInputType(field.el, binding.search, null);
1947
1948                    options = field.getOptions();
1949                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1950                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1951                        if (open) binding.search.setText(mValue.getContent());
1952                    });
1953                    search("");
1954                }
1955
1956                @Override
1957                public void afterTextChanged(Editable s) {
1958                    if (open) mValue.setContent(s.toString());
1959                    search(s.toString());
1960                }
1961
1962                @Override
1963                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1964
1965                @Override
1966                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1967
1968                protected void search(String s) {
1969                    List<Option> filteredOptions;
1970                    final String q = s.replaceAll("\\W", "").toLowerCase();
1971                    if (q == null || q.equals("")) {
1972                        filteredOptions = options;
1973                    } else {
1974                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1975                    }
1976                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1977                    binding.list.setAdapter(adapter);
1978
1979                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1980                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1981                }
1982            }
1983
1984            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1985                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1986                    super(binding);
1987                    binding.open.addTextChangedListener(this);
1988                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1989                        @Override
1990                        public View getView(int position, View convertView, ViewGroup parent) {
1991                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1992                            v.setId(position);
1993                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1994                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1995                            return v;
1996                        }
1997                    };
1998                }
1999                protected Element mValue = null;
2000                protected ArrayAdapter<Option> options;
2001
2002                @Override
2003                public void bind(Item item) {
2004                    Field field = (Field) item;
2005                    setTextOrHide(binding.label, field.getLabel());
2006                    setTextOrHide(binding.desc, field.getDesc());
2007
2008                    if (field.error != null) {
2009                        binding.desc.setVisibility(View.VISIBLE);
2010                        binding.desc.setText(field.error);
2011                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2012                    } else {
2013                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2014                    }
2015
2016                    mValue = field.getValue();
2017
2018                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2019                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2020                    binding.open.setText(mValue.getContent());
2021                    setupInputType(field.el, binding.open, null);
2022
2023                    options.clear();
2024                    List<Option> theOptions = field.getOptions();
2025                    options.addAll(theOptions);
2026
2027                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2028                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2029                    float maxColumnWidth = theOptions.stream().map((x) ->
2030                        StaticLayout.getDesiredWidth(x.toString(), paint)
2031                    ).max(Float::compare).orElse(new Float(0.0));
2032                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2033                        binding.radios.setNumColumns(theOptions.size());
2034                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2035                        binding.radios.setNumColumns(theOptions.size() / 2);
2036                    } else {
2037                        binding.radios.setNumColumns(1);
2038                    }
2039                    binding.radios.setAdapter(options);
2040                }
2041
2042                @Override
2043                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2044                    if (mValue == null) return;
2045
2046                    if (isChecked) {
2047                        mValue.setContent(options.getItem(radio.getId()).getValue());
2048                        binding.open.setText(mValue.getContent());
2049                    }
2050                    options.notifyDataSetChanged();
2051                }
2052
2053                @Override
2054                public void afterTextChanged(Editable s) {
2055                    if (mValue == null) return;
2056
2057                    mValue.setContent(s.toString());
2058                    options.notifyDataSetChanged();
2059                }
2060
2061                @Override
2062                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2063
2064                @Override
2065                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2066            }
2067
2068            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2069                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2070                    super(binding);
2071                    binding.spinner.setOnItemSelectedListener(this);
2072                }
2073                protected Element mValue = null;
2074
2075                @Override
2076                public void bind(Item item) {
2077                    Field field = (Field) item;
2078                    setTextOrHide(binding.label, field.getLabel());
2079                    binding.spinner.setPrompt(field.getLabel().or(""));
2080                    setTextOrHide(binding.desc, field.getDesc());
2081
2082                    mValue = field.getValue();
2083
2084                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2085                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2086                    options.addAll(field.getOptions());
2087
2088                    binding.spinner.setAdapter(options);
2089                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2090                }
2091
2092                @Override
2093                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2094                    Option o = (Option) parent.getItemAtPosition(pos);
2095                    if (mValue == null) return;
2096
2097                    mValue.setContent(o == null ? "" : o.getValue());
2098                }
2099
2100                @Override
2101                public void onNothingSelected(AdapterView<?> parent) {
2102                    mValue.setContent("");
2103                }
2104            }
2105
2106            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2107                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2108                    super(binding);
2109                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2110                        @Override
2111                        public View getView(int position, View convertView, ViewGroup parent) {
2112                            Button v = (Button) super.getView(position, convertView, parent);
2113                            v.setOnClickListener((view) -> {
2114                                loading = true;
2115                                mValue.setContent(getItem(position).getValue());
2116                                execute();
2117                            });
2118
2119                            final SVG icon = getItem(position).getIcon();
2120                            if (icon != null) {
2121                                 v.post(() -> {
2122                                     if (v.getHeight() == 0) return;
2123                                     icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2124                                     Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
2125                                     Canvas bmcanvas = new Canvas(bitmap);
2126                                     icon.renderToCanvas(bmcanvas);
2127                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
2128                                 });
2129                            }
2130
2131                            return v;
2132                        }
2133                    };
2134                }
2135                protected Element mValue = null;
2136                protected ArrayAdapter<Option> options;
2137                protected Option defaultOption = null;
2138
2139                @Override
2140                public void bind(Item item) {
2141                    Field field = (Field) item;
2142                    setTextOrHide(binding.label, field.getLabel());
2143                    setTextOrHide(binding.desc, field.getDesc());
2144
2145                    if (field.error != null) {
2146                        binding.desc.setVisibility(View.VISIBLE);
2147                        binding.desc.setText(field.error);
2148                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2149                    } else {
2150                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2151                    }
2152
2153                    mValue = field.getValue();
2154
2155                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2156                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2157                    binding.openButton.setOnClickListener((view) -> {
2158                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2159                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2160                        builder.setPositiveButton(R.string.action_execute, null);
2161                        if (field.getDesc().isPresent()) {
2162                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2163                        }
2164                        dialogBinding.inputEditText.requestFocus();
2165                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2166                        builder.setView(dialogBinding.getRoot());
2167                        builder.setNegativeButton(R.string.cancel, null);
2168                        final AlertDialog dialog = builder.create();
2169                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2170                        dialog.show();
2171                        View.OnClickListener clickListener = v -> {
2172                            loading = true;
2173                            String value = dialogBinding.inputEditText.getText().toString();
2174                            mValue.setContent(value);
2175                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2176                            dialog.dismiss();
2177                            execute();
2178                        };
2179                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2180                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2181                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2182                            dialog.dismiss();
2183                        }));
2184                        dialog.setCanceledOnTouchOutside(false);
2185                        dialog.setOnDismissListener(dialog1 -> {
2186                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2187                        });
2188                    });
2189
2190                    options.clear();
2191                    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();
2192
2193                    defaultOption = null;
2194                    for (Option option : theOptions) {
2195                        if (option.getValue().equals(mValue.getContent())) {
2196                            defaultOption = option;
2197                            break;
2198                        }
2199                    }
2200                    if (defaultOption == null && !mValue.getContent().equals("")) {
2201                        // Synthesize default option for custom value
2202                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2203                    }
2204                    if (defaultOption == null) {
2205                        binding.defaultButton.setVisibility(View.GONE);
2206                    } else {
2207                        theOptions.remove(defaultOption);
2208                        binding.defaultButton.setVisibility(View.VISIBLE);
2209
2210                        final SVG defaultIcon = defaultOption.getIcon();
2211                        if (defaultIcon != null) {
2212                             defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2213                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2214                             Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2215                             bitmap.setDensity(display.densityDpi);
2216                             Canvas bmcanvas = new Canvas(bitmap);
2217                             defaultIcon.renderToCanvas(bmcanvas);
2218                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2219                        }
2220
2221                        binding.defaultButton.setText(defaultOption.toString());
2222                        binding.defaultButton.setOnClickListener((view) -> {
2223                            loading = true;
2224                            mValue.setContent(defaultOption.getValue());
2225                            execute();
2226                        });
2227                    }
2228
2229                    options.addAll(theOptions);
2230                    binding.buttons.setAdapter(options);
2231                }
2232            }
2233
2234            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2235                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2236                    super(binding);
2237                    binding.textinput.addTextChangedListener(this);
2238                }
2239                protected Field field = null;
2240
2241                @Override
2242                public void bind(Item item) {
2243                    field = (Field) item;
2244                    binding.textinputLayout.setHint(field.getLabel().or(""));
2245
2246                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2247                    for (String desc : field.getDesc().asSet()) {
2248                        binding.textinputLayout.setHelperText(desc);
2249                    }
2250
2251                    binding.textinputLayout.setErrorEnabled(field.error != null);
2252                    if (field.error != null) binding.textinputLayout.setError(field.error);
2253
2254                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2255                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2256                    if (suffixLabel == null) {
2257                        binding.textinputLayout.setSuffixText("");
2258                    } else {
2259                        binding.textinputLayout.setSuffixText(suffixLabel);
2260                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2261                    }
2262
2263                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2264                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2265
2266                    binding.textinput.setText(String.join("\n", field.getValues()));
2267                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2268                }
2269
2270                @Override
2271                public void afterTextChanged(Editable s) {
2272                    if (field == null) return;
2273
2274                    field.setValues(List.of(s.toString().split("\n")));
2275                }
2276
2277                @Override
2278                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2279
2280                @Override
2281                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2282            }
2283
2284            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2285                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2286                protected String boundUrl = "";
2287
2288                @Override
2289                public void bind(Item oob) {
2290                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2291                    binding.webview.getSettings().setJavaScriptEnabled(true);
2292                    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");
2293                    binding.webview.getSettings().setDatabaseEnabled(true);
2294                    binding.webview.getSettings().setDomStorageEnabled(true);
2295                    binding.webview.setWebChromeClient(new WebChromeClient() {
2296                        @Override
2297                        public void onProgressChanged(WebView view, int newProgress) {
2298                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2299                            binding.progressbar.setProgress(newProgress);
2300                        }
2301                    });
2302                    binding.webview.setWebViewClient(new WebViewClient() {
2303                        @Override
2304                        public void onPageFinished(WebView view, String url) {
2305                            super.onPageFinished(view, url);
2306                            mTitle = view.getTitle();
2307                            ConversationPagerAdapter.this.notifyDataSetChanged();
2308                        }
2309                    });
2310                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2311                    if (!boundUrl.equals(url)) {
2312                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2313                        binding.webview.loadUrl(url);
2314                        boundUrl = url;
2315                    }
2316                }
2317
2318                class JsObject {
2319                    @JavascriptInterface
2320                    public void execute() { execute("execute"); }
2321
2322                    @JavascriptInterface
2323                    public void execute(String action) {
2324                        getView().post(() -> {
2325                            actionToWebview = null;
2326                            if(CommandSession.this.execute(action)) {
2327                                removeSession(CommandSession.this);
2328                            }
2329                        });
2330                    }
2331
2332                    @JavascriptInterface
2333                    public void preventDefault() {
2334                        actionToWebview = binding.webview;
2335                    }
2336                }
2337            }
2338
2339            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2340                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2341
2342                @Override
2343                public void bind(Item item) { }
2344            }
2345
2346            class Item {
2347                protected Element el;
2348                protected int viewType;
2349                protected String error = null;
2350
2351                Item(Element el, int viewType) {
2352                    this.el = el;
2353                    this.viewType = viewType;
2354                }
2355
2356                public boolean validate() {
2357                    error = null;
2358                    return true;
2359                }
2360            }
2361
2362            class Field extends Item {
2363                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2364
2365                @Override
2366                public boolean validate() {
2367                    if (!super.validate()) return false;
2368                    if (el.findChild("required", "jabber:x:data") == null) return true;
2369                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2370
2371                    error = "this value is required";
2372                    return false;
2373                }
2374
2375                public String getVar() {
2376                    return el.getAttribute("var");
2377                }
2378
2379                public Optional<String> getType() {
2380                    return Optional.fromNullable(el.getAttribute("type"));
2381                }
2382
2383                public Optional<String> getLabel() {
2384                    String label = el.getAttribute("label");
2385                    if (label == null) label = getVar();
2386                    return Optional.fromNullable(label);
2387                }
2388
2389                public Optional<String> getDesc() {
2390                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2391                }
2392
2393                public Element getValue() {
2394                    Element value = el.findChild("value", "jabber:x:data");
2395                    if (value == null) {
2396                        value = el.addChild("value", "jabber:x:data");
2397                    }
2398                    return value;
2399                }
2400
2401                public void setValues(List<String> values) {
2402                    for(Element child : el.getChildren()) {
2403                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2404                            el.removeChild(child);
2405                        }
2406                    }
2407
2408                    for (String value : values) {
2409                        el.addChild("value", "jabber:x:data").setContent(value);
2410                    }
2411                }
2412
2413                public List<String> getValues() {
2414                    List<String> values = new ArrayList<>();
2415                    for(Element child : el.getChildren()) {
2416                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2417                            values.add(child.getContent());
2418                        }
2419                    }
2420                    return values;
2421                }
2422
2423                public List<Option> getOptions() {
2424                    return Option.forField(el);
2425                }
2426            }
2427
2428            class Cell extends Item {
2429                protected Field reported;
2430
2431                Cell(Field reported, Element item) {
2432                    super(item, TYPE_RESULT_CELL);
2433                    this.reported = reported;
2434                }
2435            }
2436
2437            protected Field mkField(Element el) {
2438                int viewType = -1;
2439
2440                String formType = responseElement.getAttribute("type");
2441                if (formType != null) {
2442                    String fieldType = el.getAttribute("type");
2443                    if (fieldType == null) fieldType = "text-single";
2444
2445                    if (formType.equals("result") || fieldType.equals("fixed")) {
2446                        viewType = TYPE_RESULT_FIELD;
2447                    } else if (formType.equals("form")) {
2448                        if (fieldType.equals("boolean")) {
2449                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2450                                viewType = TYPE_BUTTON_GRID_FIELD;
2451                            } else {
2452                                viewType = TYPE_CHECKBOX_FIELD;
2453                            }
2454                        } else if (fieldType.equals("list-single")) {
2455                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2456                            if (Option.forField(el).size() > 9) {
2457                                viewType = TYPE_SEARCH_LIST_FIELD;
2458                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2459                                viewType = TYPE_BUTTON_GRID_FIELD;
2460                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2461                                viewType = TYPE_RADIO_EDIT_FIELD;
2462                            } else {
2463                                viewType = TYPE_SPINNER_FIELD;
2464                            }
2465                        } else {
2466                            viewType = TYPE_TEXT_FIELD;
2467                        }
2468                    }
2469
2470                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2471                }
2472
2473                return null;
2474            }
2475
2476            protected Item mkItem(Element el, int pos) {
2477                int viewType = -1;
2478
2479                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2480                    if (el.getName().equals("note")) {
2481                        viewType = TYPE_NOTE;
2482                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2483                        viewType = TYPE_WEB;
2484                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2485                        viewType = TYPE_NOTE;
2486                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2487                        Field field = mkField(el);
2488                        if (field != null) {
2489                            items.put(pos, field);
2490                            return field;
2491                        }
2492                    }
2493                } else if (response != null) {
2494                    viewType = TYPE_ERROR;
2495                }
2496
2497                Item item = new Item(el, viewType);
2498                items.put(pos, item);
2499                return item;
2500            }
2501
2502            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2503                protected Context ctx;
2504
2505                public ActionsAdapter(Context ctx) {
2506                    super(ctx, R.layout.simple_list_item);
2507                    this.ctx = ctx;
2508                }
2509
2510                @Override
2511                public View getView(int position, View convertView, ViewGroup parent) {
2512                    View v = super.getView(position, convertView, parent);
2513                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2514                    tv.setGravity(Gravity.CENTER);
2515                    tv.setText(getItem(position).second);
2516                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2517                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2518                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2519                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2520                    return v;
2521                }
2522
2523                public int getPosition(String s) {
2524                    for(int i = 0; i < getCount(); i++) {
2525                        if (getItem(i).first.equals(s)) return i;
2526                    }
2527                    return -1;
2528                }
2529
2530                public int countExceptCancel() {
2531                    int count = 0;
2532                    for(int i = 0; i < getCount(); i++) {
2533                        if (!getItem(i).first.equals("cancel")) count++;
2534                    }
2535                    return count;
2536                }
2537
2538                public void clearExceptCancel() {
2539                    Pair<String,String> cancelItem = null;
2540                    for(int i = 0; i < getCount(); i++) {
2541                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2542                    }
2543                    clear();
2544                    if (cancelItem != null) add(cancelItem);
2545                }
2546            }
2547
2548            final int TYPE_ERROR = 1;
2549            final int TYPE_NOTE = 2;
2550            final int TYPE_WEB = 3;
2551            final int TYPE_RESULT_FIELD = 4;
2552            final int TYPE_TEXT_FIELD = 5;
2553            final int TYPE_CHECKBOX_FIELD = 6;
2554            final int TYPE_SPINNER_FIELD = 7;
2555            final int TYPE_RADIO_EDIT_FIELD = 8;
2556            final int TYPE_RESULT_CELL = 9;
2557            final int TYPE_PROGRESSBAR = 10;
2558            final int TYPE_SEARCH_LIST_FIELD = 11;
2559            final int TYPE_ITEM_CARD = 12;
2560            final int TYPE_BUTTON_GRID_FIELD = 13;
2561
2562            protected boolean loading = false;
2563            protected Timer loadingTimer = new Timer();
2564            protected String mTitle;
2565            protected String mNode;
2566            protected CommandPageBinding mBinding = null;
2567            protected IqPacket response = null;
2568            protected Element responseElement = null;
2569            protected List<Field> reported = null;
2570            protected SparseArray<Item> items = new SparseArray<>();
2571            protected XmppConnectionService xmppConnectionService;
2572            protected ActionsAdapter actionsAdapter;
2573            protected GridLayoutManager layoutManager;
2574            protected WebView actionToWebview = null;
2575            protected int fillableFieldCount = 0;
2576            protected IqPacket pendingResponsePacket = null;
2577
2578            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2579                loading();
2580                mTitle = title;
2581                mNode = node;
2582                this.xmppConnectionService = xmppConnectionService;
2583                if (mPager != null) setupLayoutManager();
2584                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2585                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2586                    @Override
2587                    public void onChanged() {
2588                        if (mBinding == null) return;
2589
2590                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2591                    }
2592
2593                    @Override
2594                    public void onInvalidated() {}
2595                });
2596            }
2597
2598            public String getTitle() {
2599                return mTitle;
2600            }
2601
2602            public String getNode() {
2603                return mNode;
2604            }
2605
2606            public void updateWithResponse(final IqPacket iq) {
2607                if (getView() != null && getView().isAttachedToWindow()) {
2608                    getView().post(() -> updateWithResponseUiThread(iq));
2609                } else {
2610                    pendingResponsePacket = iq;
2611                }
2612            }
2613
2614            protected void updateWithResponseUiThread(final IqPacket iq) {
2615                this.loadingTimer.cancel();
2616                this.loadingTimer = new Timer();
2617                this.loading = false;
2618                this.responseElement = null;
2619                this.fillableFieldCount = 0;
2620                this.reported = null;
2621                this.response = iq;
2622                this.items.clear();
2623                this.actionsAdapter.clear();
2624                layoutManager.setSpanCount(1);
2625
2626                boolean actionsCleared = false;
2627                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2628                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2629                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2630                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2631                    }
2632
2633                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2634                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2635                    }
2636
2637                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2638                    if (actions != null) {
2639                        for (Element action : actions.getChildren()) {
2640                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2641                            if ("execute".equals(action.getName())) continue;
2642
2643                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2644                        }
2645                    }
2646
2647                    for (Element el : command.getChildren()) {
2648                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2649                            Data form = Data.parse(el);
2650                            String title = form.getTitle();
2651                            if (title != null) {
2652                                mTitle = title;
2653                                ConversationPagerAdapter.this.notifyDataSetChanged();
2654                            }
2655
2656                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2657                                this.responseElement = el;
2658                                setupReported(el.findChild("reported", "jabber:x:data"));
2659                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2660                            }
2661
2662                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2663                            if (actionList != null) {
2664                                actionsAdapter.clear();
2665
2666                                for (Option action : actionList.getOptions()) {
2667                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2668                                }
2669                            }
2670
2671                            String fillableFieldType = null;
2672                            String fillableFieldValue = null;
2673                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2674                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2675                                    fillableFieldType = field.getType();
2676                                    fillableFieldValue = field.getValue();
2677                                    fillableFieldCount++;
2678                                }
2679                            }
2680
2681                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2682                                actionsCleared = true;
2683                                actionsAdapter.clearExceptCancel();
2684                            }
2685                            break;
2686                        }
2687                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2688                            String url = el.findChildContent("url", "jabber:x:oob");
2689                            if (url != null) {
2690                                String scheme = Uri.parse(url).getScheme();
2691                                if (scheme.equals("http") || scheme.equals("https")) {
2692                                    this.responseElement = el;
2693                                    break;
2694                                }
2695                                if (scheme.equals("xmpp")) {
2696                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2697                                    intent.setAction(Intent.ACTION_VIEW);
2698                                    intent.setData(Uri.parse(url));
2699                                    getView().getContext().startActivity(intent);
2700                                    break;
2701                                }
2702                            }
2703                        }
2704                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2705                            this.responseElement = el;
2706                            break;
2707                        }
2708                    }
2709
2710                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2711                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2712                            if (xmppConnectionService.isOnboarding()) {
2713                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2714                                    xmppConnectionService.deleteAccount(getAccount());
2715                                } else {
2716                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2717                                        removeSession(this);
2718                                        return;
2719                                    } else {
2720                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2721                                        xmppConnectionService.deleteAccount(getAccount());
2722                                    }
2723                                }
2724                            }
2725                            xmppConnectionService.archiveConversation(Conversation.this);
2726                        }
2727
2728                        removeSession(this);
2729                        return;
2730                    }
2731
2732                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2733                        // No actions have been given, but we are not done?
2734                        // This is probably a spec violation, but we should do *something*
2735                        actionsAdapter.add(Pair.create("execute", "execute"));
2736                    }
2737
2738                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2739                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2740                            actionsAdapter.add(Pair.create("close", "close"));
2741                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2742                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2743                        }
2744                    }
2745                }
2746
2747                if (actionsAdapter.isEmpty()) {
2748                    actionsAdapter.add(Pair.create("close", "close"));
2749                }
2750
2751                actionsAdapter.sort((x, y) -> {
2752                    if (x.first.equals("cancel")) return -1;
2753                    if (y.first.equals("cancel")) return 1;
2754                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2755                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2756                    return 0;
2757                });
2758
2759                Data dataForm = null;
2760                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2761                if (mNode.equals("jabber:iq:register") &&
2762                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
2763                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2764
2765
2766                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2767                    execute();
2768                }
2769                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2770                notifyDataSetChanged();
2771            }
2772
2773            protected void setupReported(Element el) {
2774                if (el == null) {
2775                    reported = null;
2776                    return;
2777                }
2778
2779                reported = new ArrayList<>();
2780                for (Element fieldEl : el.getChildren()) {
2781                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2782                    reported.add(mkField(fieldEl));
2783                }
2784            }
2785
2786            @Override
2787            public int getItemCount() {
2788                if (loading) return 1;
2789                if (response == null) return 0;
2790                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2791                    int i = 0;
2792                    for (Element el : responseElement.getChildren()) {
2793                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2794                        if (el.getName().equals("title")) continue;
2795                        if (el.getName().equals("field")) {
2796                            String type = el.getAttribute("type");
2797                            if (type != null && type.equals("hidden")) continue;
2798                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2799                        }
2800
2801                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2802                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2803                                if (el.getName().equals("reported")) continue;
2804                                i += 1;
2805                            } else {
2806                                if (reported != null) i += reported.size();
2807                            }
2808                            continue;
2809                        }
2810
2811                        i++;
2812                    }
2813                    return i;
2814                }
2815                return 1;
2816            }
2817
2818            public Item getItem(int position) {
2819                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2820                if (items.get(position) != null) return items.get(position);
2821                if (response == null) return null;
2822
2823                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2824                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2825                        int i = 0;
2826                        for (Element el : responseElement.getChildren()) {
2827                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2828                            if (el.getName().equals("title")) continue;
2829                            if (el.getName().equals("field")) {
2830                                String type = el.getAttribute("type");
2831                                if (type != null && type.equals("hidden")) continue;
2832                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2833                            }
2834
2835                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2836                                Cell cell = null;
2837
2838                                if (reported != null) {
2839                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2840                                        if (el.getName().equals("reported")) continue;
2841                                        if (i == position) {
2842                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2843                                            return items.get(position);
2844                                        }
2845                                    } else {
2846                                        if (reported.size() > position - i) {
2847                                            Field reportedField = reported.get(position - i);
2848                                            Element itemField = null;
2849                                            if (el.getName().equals("item")) {
2850                                                for (Element subel : el.getChildren()) {
2851                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2852                                                       itemField = subel;
2853                                                       break;
2854                                                    }
2855                                                }
2856                                            }
2857                                            cell = new Cell(reportedField, itemField);
2858                                        } else {
2859                                            i += reported.size();
2860                                            continue;
2861                                        }
2862                                    }
2863                                }
2864
2865                                if (cell != null) {
2866                                    items.put(position, cell);
2867                                    return cell;
2868                                }
2869                            }
2870
2871                            if (i < position) {
2872                                i++;
2873                                continue;
2874                            }
2875
2876                            return mkItem(el, position);
2877                        }
2878                    }
2879                }
2880
2881                return mkItem(responseElement == null ? response : responseElement, position);
2882            }
2883
2884            @Override
2885            public int getItemViewType(int position) {
2886                return getItem(position).viewType;
2887            }
2888
2889            @Override
2890            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2891                switch(viewType) {
2892                    case TYPE_ERROR: {
2893                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2894                        return new ErrorViewHolder(binding);
2895                    }
2896                    case TYPE_NOTE: {
2897                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2898                        return new NoteViewHolder(binding);
2899                    }
2900                    case TYPE_WEB: {
2901                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2902                        return new WebViewHolder(binding);
2903                    }
2904                    case TYPE_RESULT_FIELD: {
2905                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2906                        return new ResultFieldViewHolder(binding);
2907                    }
2908                    case TYPE_RESULT_CELL: {
2909                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2910                        return new ResultCellViewHolder(binding);
2911                    }
2912                    case TYPE_ITEM_CARD: {
2913                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2914                        return new ItemCardViewHolder(binding);
2915                    }
2916                    case TYPE_CHECKBOX_FIELD: {
2917                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2918                        return new CheckboxFieldViewHolder(binding);
2919                    }
2920                    case TYPE_SEARCH_LIST_FIELD: {
2921                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2922                        return new SearchListFieldViewHolder(binding);
2923                    }
2924                    case TYPE_RADIO_EDIT_FIELD: {
2925                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2926                        return new RadioEditFieldViewHolder(binding);
2927                    }
2928                    case TYPE_SPINNER_FIELD: {
2929                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2930                        return new SpinnerFieldViewHolder(binding);
2931                    }
2932                    case TYPE_BUTTON_GRID_FIELD: {
2933                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2934                        return new ButtonGridFieldViewHolder(binding);
2935                    }
2936                    case TYPE_TEXT_FIELD: {
2937                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2938                        return new TextFieldViewHolder(binding);
2939                    }
2940                    case TYPE_PROGRESSBAR: {
2941                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2942                        return new ProgressBarViewHolder(binding);
2943                    }
2944                    default:
2945                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2946                }
2947            }
2948
2949            @Override
2950            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2951                viewHolder.bind(getItem(position));
2952            }
2953
2954            public View getView() {
2955                if (mBinding == null) return null;
2956                return mBinding.getRoot();
2957            }
2958
2959            public boolean validate() {
2960                int count = getItemCount();
2961                boolean isValid = true;
2962                for (int i = 0; i < count; i++) {
2963                    boolean oneIsValid = getItem(i).validate();
2964                    isValid = isValid && oneIsValid;
2965                }
2966                notifyDataSetChanged();
2967                return isValid;
2968            }
2969
2970            public boolean execute() {
2971                return execute("execute");
2972            }
2973
2974            public boolean execute(int actionPosition) {
2975                return execute(actionsAdapter.getItem(actionPosition).first);
2976            }
2977
2978            public boolean execute(String action) {
2979                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2980
2981                if (response == null) return true;
2982                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2983                if (command == null) return true;
2984                String status = command.getAttribute("status");
2985                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2986
2987                if (actionToWebview != null && !action.equals("cancel")) {
2988                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2989                    return false;
2990                }
2991
2992                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2993                packet.setTo(response.getFrom());
2994                final Element c = packet.addChild("command", Namespace.COMMANDS);
2995                c.setAttribute("node", mNode);
2996                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2997
2998                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2999                if (!action.equals("cancel") &&
3000                    !action.equals("prev") &&
3001                    responseElement != null &&
3002                    responseElement.getName().equals("x") &&
3003                    responseElement.getNamespace().equals("jabber:x:data") &&
3004                    formType != null && formType.equals("form")) {
3005
3006                    Data form = Data.parse(responseElement);
3007                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3008                    if (actionList != null) {
3009                        actionList.setValue(action);
3010                        c.setAttribute("action", "execute");
3011                    }
3012
3013                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3014                        if (form.getValue("gateway-jid") == null) {
3015                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3016                        } else {
3017                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3018                        }
3019                    }
3020
3021                    responseElement.setAttribute("type", "submit");
3022                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3023                    if (rsm != null) {
3024                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3025                        max.setContent("1000");
3026                        rsm.addChild(max);
3027                    }
3028
3029                    c.addChild(responseElement);
3030                }
3031
3032                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3033
3034                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3035                    updateWithResponse(iq);
3036                });
3037
3038                loading();
3039                return false;
3040            }
3041
3042            public void refresh() {
3043                notifyDataSetChanged();
3044            }
3045
3046            protected void loading() {
3047                View v = getView();
3048                loadingTimer.schedule(new TimerTask() {
3049                    @Override
3050                    public void run() {
3051                        View v2 = getView();
3052                        loading = true;
3053
3054                        if (v == null && v2 == null) return;
3055                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3056                    }
3057                }, 500);
3058            }
3059
3060            protected GridLayoutManager setupLayoutManager() {
3061                int spanCount = 1;
3062
3063                Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3064                if (reported != null) {
3065                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3066                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3067                    float tableHeaderWidth = reported.stream().reduce(
3068                        0f,
3069                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3070                        (a, b) -> a + b
3071                    );
3072
3073                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3074                }
3075
3076                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3077                    items.clear();
3078                    notifyDataSetChanged();
3079                }
3080
3081                layoutManager = new GridLayoutManager(ctx, spanCount);
3082                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3083                    @Override
3084                    public int getSpanSize(int position) {
3085                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3086                        return 1;
3087                    }
3088                });
3089                return layoutManager;
3090            }
3091
3092            protected void setBinding(CommandPageBinding b) {
3093                mBinding = b;
3094                // https://stackoverflow.com/a/32350474/8611
3095                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3096                    @Override
3097                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3098                        if(rv.getChildCount() > 0) {
3099                            int[] location = new int[2];
3100                            rv.getLocationOnScreen(location);
3101                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3102                            if (childView instanceof ViewGroup) {
3103                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3104                            }
3105                            int action = e.getAction();
3106                            switch (action) {
3107                                case MotionEvent.ACTION_DOWN:
3108                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3109                                        rv.requestDisallowInterceptTouchEvent(true);
3110                                    }
3111                                case MotionEvent.ACTION_UP:
3112                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3113                                        rv.requestDisallowInterceptTouchEvent(true);
3114                                    }
3115                            }
3116                        }
3117
3118                        return false;
3119                    }
3120
3121                    @Override
3122                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3123
3124                    @Override
3125                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3126                });
3127                mBinding.form.setLayoutManager(setupLayoutManager());
3128                mBinding.form.setAdapter(this);
3129                mBinding.actions.setAdapter(actionsAdapter);
3130                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3131                    if (execute(pos)) {
3132                        removeSession(CommandSession.this);
3133                    }
3134                });
3135
3136                actionsAdapter.notifyDataSetChanged();
3137
3138                if (pendingResponsePacket != null) {
3139                    final IqPacket pending = pendingResponsePacket;
3140                    pendingResponsePacket = null;
3141                    updateWithResponseUiThread(pending);
3142                }
3143            }
3144
3145            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3146                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3147                setBinding(binding);
3148                return binding.getRoot();
3149            }
3150
3151            // https://stackoverflow.com/a/36037991/8611
3152            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3153                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3154                    View child = viewGroup.getChildAt(i);
3155                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3156                        View foundView = findViewAt((ViewGroup) child, x, y);
3157                        if (foundView != null && foundView.isShown()) {
3158                            return foundView;
3159                        }
3160                    } else {
3161                        int[] location = new int[2];
3162                        child.getLocationOnScreen(location);
3163                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3164                        if (rect.contains((int)x, (int)y)) {
3165                            return child;
3166                        }
3167                    }
3168                }
3169
3170                return null;
3171            }
3172        }
3173    }
3174}