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                        } else if ("xs:anyURI".equals(datatype)) {
1842                            text.setSpan(new FixedURLSpan(text.toString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1843                        } else if ("html:tel".equals(datatype)) {
1844                            try {
1845                                text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString())), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1846                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1847                        }
1848
1849                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1850                        binding.text.setText(text);
1851
1852                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1853                        method.setOnLinkLongClickListener((tv, url) -> {
1854                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1855                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1856                            return true;
1857                        });
1858                        binding.text.setMovementMethod(method);
1859                    }
1860                }
1861            }
1862
1863            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1864                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1865
1866                @Override
1867                public void bind(Item item) {
1868                    binding.fields.removeAllViews();
1869
1870                    for (Field field : reported) {
1871                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1872                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1873                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1874                        param.width = 0;
1875                        row.getRoot().setLayoutParams(param);
1876                        binding.fields.addView(row.getRoot());
1877                        for (Element el : item.el.getChildren()) {
1878                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1879                                for (String label : field.getLabel().asSet()) {
1880                                    el.setAttribute("label", label);
1881                                }
1882                                for (String desc : field.getDesc().asSet()) {
1883                                    el.setAttribute("desc", desc);
1884                                }
1885                                for (String type : field.getType().asSet()) {
1886                                    el.setAttribute("type", type);
1887                                }
1888                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1889                                if (validate != null) el.addChild(validate);
1890                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1891                            }
1892                        }
1893                    }
1894                }
1895            }
1896
1897            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1898                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1899                    super(binding);
1900                    binding.row.setOnClickListener((v) -> {
1901                        binding.checkbox.toggle();
1902                    });
1903                    binding.checkbox.setOnCheckedChangeListener(this);
1904                }
1905                protected Element mValue = null;
1906
1907                @Override
1908                public void bind(Item item) {
1909                    Field field = (Field) item;
1910                    binding.label.setText(field.getLabel().or(""));
1911                    setTextOrHide(binding.desc, field.getDesc());
1912                    mValue = field.getValue();
1913                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1914                }
1915
1916                @Override
1917                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1918                    if (mValue == null) return;
1919
1920                    mValue.setContent(isChecked ? "true" : "false");
1921                }
1922            }
1923
1924            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1925                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1926                    super(binding);
1927                    binding.search.addTextChangedListener(this);
1928                }
1929                protected Element mValue = null;
1930                List<Option> options = new ArrayList<>();
1931                protected ArrayAdapter<Option> adapter;
1932                protected boolean open;
1933
1934                @Override
1935                public void bind(Item item) {
1936                    Field field = (Field) item;
1937                    setTextOrHide(binding.label, field.getLabel());
1938                    setTextOrHide(binding.desc, field.getDesc());
1939
1940                    if (field.error != null) {
1941                        binding.desc.setVisibility(View.VISIBLE);
1942                        binding.desc.setText(field.error);
1943                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1944                    } else {
1945                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1946                    }
1947
1948                    mValue = field.getValue();
1949
1950                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1951                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1952                    setupInputType(field.el, binding.search, null);
1953
1954                    options = field.getOptions();
1955                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1956                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1957                        if (open) binding.search.setText(mValue.getContent());
1958                    });
1959                    search("");
1960                }
1961
1962                @Override
1963                public void afterTextChanged(Editable s) {
1964                    if (open) mValue.setContent(s.toString());
1965                    search(s.toString());
1966                }
1967
1968                @Override
1969                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1970
1971                @Override
1972                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1973
1974                protected void search(String s) {
1975                    List<Option> filteredOptions;
1976                    final String q = s.replaceAll("\\W", "").toLowerCase();
1977                    if (q == null || q.equals("")) {
1978                        filteredOptions = options;
1979                    } else {
1980                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1981                    }
1982                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1983                    binding.list.setAdapter(adapter);
1984
1985                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1986                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1987                }
1988            }
1989
1990            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1991                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1992                    super(binding);
1993                    binding.open.addTextChangedListener(this);
1994                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1995                        @Override
1996                        public View getView(int position, View convertView, ViewGroup parent) {
1997                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1998                            v.setId(position);
1999                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2000                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2001                            return v;
2002                        }
2003                    };
2004                }
2005                protected Element mValue = null;
2006                protected ArrayAdapter<Option> options;
2007
2008                @Override
2009                public void bind(Item item) {
2010                    Field field = (Field) item;
2011                    setTextOrHide(binding.label, field.getLabel());
2012                    setTextOrHide(binding.desc, field.getDesc());
2013
2014                    if (field.error != null) {
2015                        binding.desc.setVisibility(View.VISIBLE);
2016                        binding.desc.setText(field.error);
2017                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2018                    } else {
2019                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2020                    }
2021
2022                    mValue = field.getValue();
2023
2024                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2025                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2026                    binding.open.setText(mValue.getContent());
2027                    setupInputType(field.el, binding.open, null);
2028
2029                    options.clear();
2030                    List<Option> theOptions = field.getOptions();
2031                    options.addAll(theOptions);
2032
2033                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2034                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2035                    float maxColumnWidth = theOptions.stream().map((x) ->
2036                        StaticLayout.getDesiredWidth(x.toString(), paint)
2037                    ).max(Float::compare).orElse(new Float(0.0));
2038                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2039                        binding.radios.setNumColumns(theOptions.size());
2040                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2041                        binding.radios.setNumColumns(theOptions.size() / 2);
2042                    } else {
2043                        binding.radios.setNumColumns(1);
2044                    }
2045                    binding.radios.setAdapter(options);
2046                }
2047
2048                @Override
2049                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2050                    if (mValue == null) return;
2051
2052                    if (isChecked) {
2053                        mValue.setContent(options.getItem(radio.getId()).getValue());
2054                        binding.open.setText(mValue.getContent());
2055                    }
2056                    options.notifyDataSetChanged();
2057                }
2058
2059                @Override
2060                public void afterTextChanged(Editable s) {
2061                    if (mValue == null) return;
2062
2063                    mValue.setContent(s.toString());
2064                    options.notifyDataSetChanged();
2065                }
2066
2067                @Override
2068                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2069
2070                @Override
2071                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2072            }
2073
2074            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2075                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2076                    super(binding);
2077                    binding.spinner.setOnItemSelectedListener(this);
2078                }
2079                protected Element mValue = null;
2080
2081                @Override
2082                public void bind(Item item) {
2083                    Field field = (Field) item;
2084                    setTextOrHide(binding.label, field.getLabel());
2085                    binding.spinner.setPrompt(field.getLabel().or(""));
2086                    setTextOrHide(binding.desc, field.getDesc());
2087
2088                    mValue = field.getValue();
2089
2090                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2091                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2092                    options.addAll(field.getOptions());
2093
2094                    binding.spinner.setAdapter(options);
2095                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2096                }
2097
2098                @Override
2099                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2100                    Option o = (Option) parent.getItemAtPosition(pos);
2101                    if (mValue == null) return;
2102
2103                    mValue.setContent(o == null ? "" : o.getValue());
2104                }
2105
2106                @Override
2107                public void onNothingSelected(AdapterView<?> parent) {
2108                    mValue.setContent("");
2109                }
2110            }
2111
2112            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2113                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2114                    super(binding);
2115                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2116                        @Override
2117                        public View getView(int position, View convertView, ViewGroup parent) {
2118                            Button v = (Button) super.getView(position, convertView, parent);
2119                            v.setOnClickListener((view) -> {
2120                                loading = true;
2121                                mValue.setContent(getItem(position).getValue());
2122                                execute();
2123                            });
2124
2125                            final SVG icon = getItem(position).getIcon();
2126                            if (icon != null) {
2127                                 v.post(() -> {
2128                                     if (v.getHeight() == 0) return;
2129                                     icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2130                                     Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
2131                                     Canvas bmcanvas = new Canvas(bitmap);
2132                                     icon.renderToCanvas(bmcanvas);
2133                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
2134                                 });
2135                            }
2136
2137                            return v;
2138                        }
2139                    };
2140                }
2141                protected Element mValue = null;
2142                protected ArrayAdapter<Option> options;
2143                protected Option defaultOption = null;
2144
2145                @Override
2146                public void bind(Item item) {
2147                    Field field = (Field) item;
2148                    setTextOrHide(binding.label, field.getLabel());
2149                    setTextOrHide(binding.desc, field.getDesc());
2150
2151                    if (field.error != null) {
2152                        binding.desc.setVisibility(View.VISIBLE);
2153                        binding.desc.setText(field.error);
2154                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2155                    } else {
2156                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2157                    }
2158
2159                    mValue = field.getValue();
2160
2161                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2162                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2163                    binding.openButton.setOnClickListener((view) -> {
2164                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2165                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2166                        builder.setPositiveButton(R.string.action_execute, null);
2167                        if (field.getDesc().isPresent()) {
2168                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2169                        }
2170                        dialogBinding.inputEditText.requestFocus();
2171                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2172                        builder.setView(dialogBinding.getRoot());
2173                        builder.setNegativeButton(R.string.cancel, null);
2174                        final AlertDialog dialog = builder.create();
2175                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2176                        dialog.show();
2177                        View.OnClickListener clickListener = v -> {
2178                            loading = true;
2179                            String value = dialogBinding.inputEditText.getText().toString();
2180                            mValue.setContent(value);
2181                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2182                            dialog.dismiss();
2183                            execute();
2184                        };
2185                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2186                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2187                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2188                            dialog.dismiss();
2189                        }));
2190                        dialog.setCanceledOnTouchOutside(false);
2191                        dialog.setOnDismissListener(dialog1 -> {
2192                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2193                        });
2194                    });
2195
2196                    options.clear();
2197                    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();
2198
2199                    defaultOption = null;
2200                    for (Option option : theOptions) {
2201                        if (option.getValue().equals(mValue.getContent())) {
2202                            defaultOption = option;
2203                            break;
2204                        }
2205                    }
2206                    if (defaultOption == null && !mValue.getContent().equals("")) {
2207                        // Synthesize default option for custom value
2208                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2209                    }
2210                    if (defaultOption == null) {
2211                        binding.defaultButton.setVisibility(View.GONE);
2212                    } else {
2213                        theOptions.remove(defaultOption);
2214                        binding.defaultButton.setVisibility(View.VISIBLE);
2215
2216                        final SVG defaultIcon = defaultOption.getIcon();
2217                        if (defaultIcon != null) {
2218                             defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2219                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2220                             Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2221                             bitmap.setDensity(display.densityDpi);
2222                             Canvas bmcanvas = new Canvas(bitmap);
2223                             defaultIcon.renderToCanvas(bmcanvas);
2224                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2225                        }
2226
2227                        binding.defaultButton.setText(defaultOption.toString());
2228                        binding.defaultButton.setOnClickListener((view) -> {
2229                            loading = true;
2230                            mValue.setContent(defaultOption.getValue());
2231                            execute();
2232                        });
2233                    }
2234
2235                    options.addAll(theOptions);
2236                    binding.buttons.setAdapter(options);
2237                }
2238            }
2239
2240            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2241                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2242                    super(binding);
2243                    binding.textinput.addTextChangedListener(this);
2244                }
2245                protected Field field = null;
2246
2247                @Override
2248                public void bind(Item item) {
2249                    field = (Field) item;
2250                    binding.textinputLayout.setHint(field.getLabel().or(""));
2251
2252                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2253                    for (String desc : field.getDesc().asSet()) {
2254                        binding.textinputLayout.setHelperText(desc);
2255                    }
2256
2257                    binding.textinputLayout.setErrorEnabled(field.error != null);
2258                    if (field.error != null) binding.textinputLayout.setError(field.error);
2259
2260                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2261                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2262                    if (suffixLabel == null) {
2263                        binding.textinputLayout.setSuffixText("");
2264                    } else {
2265                        binding.textinputLayout.setSuffixText(suffixLabel);
2266                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2267                    }
2268
2269                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2270                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2271
2272                    binding.textinput.setText(String.join("\n", field.getValues()));
2273                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2274                }
2275
2276                @Override
2277                public void afterTextChanged(Editable s) {
2278                    if (field == null) return;
2279
2280                    field.setValues(List.of(s.toString().split("\n")));
2281                }
2282
2283                @Override
2284                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2285
2286                @Override
2287                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2288            }
2289
2290            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2291                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2292                protected String boundUrl = "";
2293
2294                @Override
2295                public void bind(Item oob) {
2296                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2297                    binding.webview.getSettings().setJavaScriptEnabled(true);
2298                    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");
2299                    binding.webview.getSettings().setDatabaseEnabled(true);
2300                    binding.webview.getSettings().setDomStorageEnabled(true);
2301                    binding.webview.setWebChromeClient(new WebChromeClient() {
2302                        @Override
2303                        public void onProgressChanged(WebView view, int newProgress) {
2304                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2305                            binding.progressbar.setProgress(newProgress);
2306                        }
2307                    });
2308                    binding.webview.setWebViewClient(new WebViewClient() {
2309                        @Override
2310                        public void onPageFinished(WebView view, String url) {
2311                            super.onPageFinished(view, url);
2312                            mTitle = view.getTitle();
2313                            ConversationPagerAdapter.this.notifyDataSetChanged();
2314                        }
2315                    });
2316                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2317                    if (!boundUrl.equals(url)) {
2318                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2319                        binding.webview.loadUrl(url);
2320                        boundUrl = url;
2321                    }
2322                }
2323
2324                class JsObject {
2325                    @JavascriptInterface
2326                    public void execute() { execute("execute"); }
2327
2328                    @JavascriptInterface
2329                    public void execute(String action) {
2330                        getView().post(() -> {
2331                            actionToWebview = null;
2332                            if(CommandSession.this.execute(action)) {
2333                                removeSession(CommandSession.this);
2334                            }
2335                        });
2336                    }
2337
2338                    @JavascriptInterface
2339                    public void preventDefault() {
2340                        actionToWebview = binding.webview;
2341                    }
2342                }
2343            }
2344
2345            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2346                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2347
2348                @Override
2349                public void bind(Item item) { }
2350            }
2351
2352            class Item {
2353                protected Element el;
2354                protected int viewType;
2355                protected String error = null;
2356
2357                Item(Element el, int viewType) {
2358                    this.el = el;
2359                    this.viewType = viewType;
2360                }
2361
2362                public boolean validate() {
2363                    error = null;
2364                    return true;
2365                }
2366            }
2367
2368            class Field extends Item {
2369                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2370
2371                @Override
2372                public boolean validate() {
2373                    if (!super.validate()) return false;
2374                    if (el.findChild("required", "jabber:x:data") == null) return true;
2375                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2376
2377                    error = "this value is required";
2378                    return false;
2379                }
2380
2381                public String getVar() {
2382                    return el.getAttribute("var");
2383                }
2384
2385                public Optional<String> getType() {
2386                    return Optional.fromNullable(el.getAttribute("type"));
2387                }
2388
2389                public Optional<String> getLabel() {
2390                    String label = el.getAttribute("label");
2391                    if (label == null) label = getVar();
2392                    return Optional.fromNullable(label);
2393                }
2394
2395                public Optional<String> getDesc() {
2396                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2397                }
2398
2399                public Element getValue() {
2400                    Element value = el.findChild("value", "jabber:x:data");
2401                    if (value == null) {
2402                        value = el.addChild("value", "jabber:x:data");
2403                    }
2404                    return value;
2405                }
2406
2407                public void setValues(List<String> values) {
2408                    for(Element child : el.getChildren()) {
2409                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2410                            el.removeChild(child);
2411                        }
2412                    }
2413
2414                    for (String value : values) {
2415                        el.addChild("value", "jabber:x:data").setContent(value);
2416                    }
2417                }
2418
2419                public List<String> getValues() {
2420                    List<String> values = new ArrayList<>();
2421                    for(Element child : el.getChildren()) {
2422                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2423                            values.add(child.getContent());
2424                        }
2425                    }
2426                    return values;
2427                }
2428
2429                public List<Option> getOptions() {
2430                    return Option.forField(el);
2431                }
2432            }
2433
2434            class Cell extends Item {
2435                protected Field reported;
2436
2437                Cell(Field reported, Element item) {
2438                    super(item, TYPE_RESULT_CELL);
2439                    this.reported = reported;
2440                }
2441            }
2442
2443            protected Field mkField(Element el) {
2444                int viewType = -1;
2445
2446                String formType = responseElement.getAttribute("type");
2447                if (formType != null) {
2448                    String fieldType = el.getAttribute("type");
2449                    if (fieldType == null) fieldType = "text-single";
2450
2451                    if (formType.equals("result") || fieldType.equals("fixed")) {
2452                        viewType = TYPE_RESULT_FIELD;
2453                    } else if (formType.equals("form")) {
2454                        if (fieldType.equals("boolean")) {
2455                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2456                                viewType = TYPE_BUTTON_GRID_FIELD;
2457                            } else {
2458                                viewType = TYPE_CHECKBOX_FIELD;
2459                            }
2460                        } else if (fieldType.equals("list-single")) {
2461                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2462                            if (Option.forField(el).size() > 9) {
2463                                viewType = TYPE_SEARCH_LIST_FIELD;
2464                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2465                                viewType = TYPE_BUTTON_GRID_FIELD;
2466                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2467                                viewType = TYPE_RADIO_EDIT_FIELD;
2468                            } else {
2469                                viewType = TYPE_SPINNER_FIELD;
2470                            }
2471                        } else {
2472                            viewType = TYPE_TEXT_FIELD;
2473                        }
2474                    }
2475
2476                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2477                }
2478
2479                return null;
2480            }
2481
2482            protected Item mkItem(Element el, int pos) {
2483                int viewType = -1;
2484
2485                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2486                    if (el.getName().equals("note")) {
2487                        viewType = TYPE_NOTE;
2488                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2489                        viewType = TYPE_WEB;
2490                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2491                        viewType = TYPE_NOTE;
2492                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2493                        Field field = mkField(el);
2494                        if (field != null) {
2495                            items.put(pos, field);
2496                            return field;
2497                        }
2498                    }
2499                } else if (response != null) {
2500                    viewType = TYPE_ERROR;
2501                }
2502
2503                Item item = new Item(el, viewType);
2504                items.put(pos, item);
2505                return item;
2506            }
2507
2508            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2509                protected Context ctx;
2510
2511                public ActionsAdapter(Context ctx) {
2512                    super(ctx, R.layout.simple_list_item);
2513                    this.ctx = ctx;
2514                }
2515
2516                @Override
2517                public View getView(int position, View convertView, ViewGroup parent) {
2518                    View v = super.getView(position, convertView, parent);
2519                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2520                    tv.setGravity(Gravity.CENTER);
2521                    tv.setText(getItem(position).second);
2522                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2523                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2524                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2525                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2526                    return v;
2527                }
2528
2529                public int getPosition(String s) {
2530                    for(int i = 0; i < getCount(); i++) {
2531                        if (getItem(i).first.equals(s)) return i;
2532                    }
2533                    return -1;
2534                }
2535
2536                public int countExceptCancel() {
2537                    int count = 0;
2538                    for(int i = 0; i < getCount(); i++) {
2539                        if (!getItem(i).first.equals("cancel")) count++;
2540                    }
2541                    return count;
2542                }
2543
2544                public void clearExceptCancel() {
2545                    Pair<String,String> cancelItem = null;
2546                    for(int i = 0; i < getCount(); i++) {
2547                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2548                    }
2549                    clear();
2550                    if (cancelItem != null) add(cancelItem);
2551                }
2552            }
2553
2554            final int TYPE_ERROR = 1;
2555            final int TYPE_NOTE = 2;
2556            final int TYPE_WEB = 3;
2557            final int TYPE_RESULT_FIELD = 4;
2558            final int TYPE_TEXT_FIELD = 5;
2559            final int TYPE_CHECKBOX_FIELD = 6;
2560            final int TYPE_SPINNER_FIELD = 7;
2561            final int TYPE_RADIO_EDIT_FIELD = 8;
2562            final int TYPE_RESULT_CELL = 9;
2563            final int TYPE_PROGRESSBAR = 10;
2564            final int TYPE_SEARCH_LIST_FIELD = 11;
2565            final int TYPE_ITEM_CARD = 12;
2566            final int TYPE_BUTTON_GRID_FIELD = 13;
2567
2568            protected boolean loading = false;
2569            protected Timer loadingTimer = new Timer();
2570            protected String mTitle;
2571            protected String mNode;
2572            protected CommandPageBinding mBinding = null;
2573            protected IqPacket response = null;
2574            protected Element responseElement = null;
2575            protected List<Field> reported = null;
2576            protected SparseArray<Item> items = new SparseArray<>();
2577            protected XmppConnectionService xmppConnectionService;
2578            protected ActionsAdapter actionsAdapter;
2579            protected GridLayoutManager layoutManager;
2580            protected WebView actionToWebview = null;
2581            protected int fillableFieldCount = 0;
2582            protected IqPacket pendingResponsePacket = null;
2583
2584            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2585                loading();
2586                mTitle = title;
2587                mNode = node;
2588                this.xmppConnectionService = xmppConnectionService;
2589                if (mPager != null) setupLayoutManager();
2590                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2591                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2592                    @Override
2593                    public void onChanged() {
2594                        if (mBinding == null) return;
2595
2596                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2597                    }
2598
2599                    @Override
2600                    public void onInvalidated() {}
2601                });
2602            }
2603
2604            public String getTitle() {
2605                return mTitle;
2606            }
2607
2608            public String getNode() {
2609                return mNode;
2610            }
2611
2612            public void updateWithResponse(final IqPacket iq) {
2613                if (getView() != null && getView().isAttachedToWindow()) {
2614                    getView().post(() -> updateWithResponseUiThread(iq));
2615                } else {
2616                    pendingResponsePacket = iq;
2617                }
2618            }
2619
2620            protected void updateWithResponseUiThread(final IqPacket iq) {
2621                this.loadingTimer.cancel();
2622                this.loadingTimer = new Timer();
2623                this.loading = false;
2624                this.responseElement = null;
2625                this.fillableFieldCount = 0;
2626                this.reported = null;
2627                this.response = iq;
2628                this.items.clear();
2629                this.actionsAdapter.clear();
2630                layoutManager.setSpanCount(1);
2631
2632                boolean actionsCleared = false;
2633                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2634                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2635                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2636                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2637                    }
2638
2639                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2640                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2641                    }
2642
2643                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2644                    if (actions != null) {
2645                        for (Element action : actions.getChildren()) {
2646                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2647                            if ("execute".equals(action.getName())) continue;
2648
2649                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2650                        }
2651                    }
2652
2653                    for (Element el : command.getChildren()) {
2654                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2655                            Data form = Data.parse(el);
2656                            String title = form.getTitle();
2657                            if (title != null) {
2658                                mTitle = title;
2659                                ConversationPagerAdapter.this.notifyDataSetChanged();
2660                            }
2661
2662                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2663                                this.responseElement = el;
2664                                setupReported(el.findChild("reported", "jabber:x:data"));
2665                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2666                            }
2667
2668                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2669                            if (actionList != null) {
2670                                actionsAdapter.clear();
2671
2672                                for (Option action : actionList.getOptions()) {
2673                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2674                                }
2675                            }
2676
2677                            String fillableFieldType = null;
2678                            String fillableFieldValue = null;
2679                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2680                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2681                                    fillableFieldType = field.getType();
2682                                    fillableFieldValue = field.getValue();
2683                                    fillableFieldCount++;
2684                                }
2685                            }
2686
2687                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2688                                actionsCleared = true;
2689                                actionsAdapter.clearExceptCancel();
2690                            }
2691                            break;
2692                        }
2693                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2694                            String url = el.findChildContent("url", "jabber:x:oob");
2695                            if (url != null) {
2696                                String scheme = Uri.parse(url).getScheme();
2697                                if (scheme.equals("http") || scheme.equals("https")) {
2698                                    this.responseElement = el;
2699                                    break;
2700                                }
2701                                if (scheme.equals("xmpp")) {
2702                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2703                                    intent.setAction(Intent.ACTION_VIEW);
2704                                    intent.setData(Uri.parse(url));
2705                                    getView().getContext().startActivity(intent);
2706                                    break;
2707                                }
2708                            }
2709                        }
2710                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2711                            this.responseElement = el;
2712                            break;
2713                        }
2714                    }
2715
2716                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2717                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2718                            if (xmppConnectionService.isOnboarding()) {
2719                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2720                                    xmppConnectionService.deleteAccount(getAccount());
2721                                } else {
2722                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2723                                        removeSession(this);
2724                                        return;
2725                                    } else {
2726                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2727                                        xmppConnectionService.deleteAccount(getAccount());
2728                                    }
2729                                }
2730                            }
2731                            xmppConnectionService.archiveConversation(Conversation.this);
2732                        }
2733
2734                        removeSession(this);
2735                        return;
2736                    }
2737
2738                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2739                        // No actions have been given, but we are not done?
2740                        // This is probably a spec violation, but we should do *something*
2741                        actionsAdapter.add(Pair.create("execute", "execute"));
2742                    }
2743
2744                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2745                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2746                            actionsAdapter.add(Pair.create("close", "close"));
2747                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2748                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2749                        }
2750                    }
2751                }
2752
2753                if (actionsAdapter.isEmpty()) {
2754                    actionsAdapter.add(Pair.create("close", "close"));
2755                }
2756
2757                actionsAdapter.sort((x, y) -> {
2758                    if (x.first.equals("cancel")) return -1;
2759                    if (y.first.equals("cancel")) return 1;
2760                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2761                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2762                    return 0;
2763                });
2764
2765                Data dataForm = null;
2766                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2767                if (mNode.equals("jabber:iq:register") &&
2768                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
2769                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2770
2771
2772                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2773                    execute();
2774                }
2775                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2776                notifyDataSetChanged();
2777            }
2778
2779            protected void setupReported(Element el) {
2780                if (el == null) {
2781                    reported = null;
2782                    return;
2783                }
2784
2785                reported = new ArrayList<>();
2786                for (Element fieldEl : el.getChildren()) {
2787                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2788                    reported.add(mkField(fieldEl));
2789                }
2790            }
2791
2792            @Override
2793            public int getItemCount() {
2794                if (loading) return 1;
2795                if (response == null) return 0;
2796                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2797                    int i = 0;
2798                    for (Element el : responseElement.getChildren()) {
2799                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2800                        if (el.getName().equals("title")) continue;
2801                        if (el.getName().equals("field")) {
2802                            String type = el.getAttribute("type");
2803                            if (type != null && type.equals("hidden")) continue;
2804                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2805                        }
2806
2807                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2808                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2809                                if (el.getName().equals("reported")) continue;
2810                                i += 1;
2811                            } else {
2812                                if (reported != null) i += reported.size();
2813                            }
2814                            continue;
2815                        }
2816
2817                        i++;
2818                    }
2819                    return i;
2820                }
2821                return 1;
2822            }
2823
2824            public Item getItem(int position) {
2825                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2826                if (items.get(position) != null) return items.get(position);
2827                if (response == null) return null;
2828
2829                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2830                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2831                        int i = 0;
2832                        for (Element el : responseElement.getChildren()) {
2833                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2834                            if (el.getName().equals("title")) continue;
2835                            if (el.getName().equals("field")) {
2836                                String type = el.getAttribute("type");
2837                                if (type != null && type.equals("hidden")) continue;
2838                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2839                            }
2840
2841                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2842                                Cell cell = null;
2843
2844                                if (reported != null) {
2845                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2846                                        if (el.getName().equals("reported")) continue;
2847                                        if (i == position) {
2848                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2849                                            return items.get(position);
2850                                        }
2851                                    } else {
2852                                        if (reported.size() > position - i) {
2853                                            Field reportedField = reported.get(position - i);
2854                                            Element itemField = null;
2855                                            if (el.getName().equals("item")) {
2856                                                for (Element subel : el.getChildren()) {
2857                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2858                                                       itemField = subel;
2859                                                       break;
2860                                                    }
2861                                                }
2862                                            }
2863                                            cell = new Cell(reportedField, itemField);
2864                                        } else {
2865                                            i += reported.size();
2866                                            continue;
2867                                        }
2868                                    }
2869                                }
2870
2871                                if (cell != null) {
2872                                    items.put(position, cell);
2873                                    return cell;
2874                                }
2875                            }
2876
2877                            if (i < position) {
2878                                i++;
2879                                continue;
2880                            }
2881
2882                            return mkItem(el, position);
2883                        }
2884                    }
2885                }
2886
2887                return mkItem(responseElement == null ? response : responseElement, position);
2888            }
2889
2890            @Override
2891            public int getItemViewType(int position) {
2892                return getItem(position).viewType;
2893            }
2894
2895            @Override
2896            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2897                switch(viewType) {
2898                    case TYPE_ERROR: {
2899                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2900                        return new ErrorViewHolder(binding);
2901                    }
2902                    case TYPE_NOTE: {
2903                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2904                        return new NoteViewHolder(binding);
2905                    }
2906                    case TYPE_WEB: {
2907                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2908                        return new WebViewHolder(binding);
2909                    }
2910                    case TYPE_RESULT_FIELD: {
2911                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2912                        return new ResultFieldViewHolder(binding);
2913                    }
2914                    case TYPE_RESULT_CELL: {
2915                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2916                        return new ResultCellViewHolder(binding);
2917                    }
2918                    case TYPE_ITEM_CARD: {
2919                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2920                        return new ItemCardViewHolder(binding);
2921                    }
2922                    case TYPE_CHECKBOX_FIELD: {
2923                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2924                        return new CheckboxFieldViewHolder(binding);
2925                    }
2926                    case TYPE_SEARCH_LIST_FIELD: {
2927                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2928                        return new SearchListFieldViewHolder(binding);
2929                    }
2930                    case TYPE_RADIO_EDIT_FIELD: {
2931                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2932                        return new RadioEditFieldViewHolder(binding);
2933                    }
2934                    case TYPE_SPINNER_FIELD: {
2935                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2936                        return new SpinnerFieldViewHolder(binding);
2937                    }
2938                    case TYPE_BUTTON_GRID_FIELD: {
2939                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2940                        return new ButtonGridFieldViewHolder(binding);
2941                    }
2942                    case TYPE_TEXT_FIELD: {
2943                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2944                        return new TextFieldViewHolder(binding);
2945                    }
2946                    case TYPE_PROGRESSBAR: {
2947                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2948                        return new ProgressBarViewHolder(binding);
2949                    }
2950                    default:
2951                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2952                }
2953            }
2954
2955            @Override
2956            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2957                viewHolder.bind(getItem(position));
2958            }
2959
2960            public View getView() {
2961                if (mBinding == null) return null;
2962                return mBinding.getRoot();
2963            }
2964
2965            public boolean validate() {
2966                int count = getItemCount();
2967                boolean isValid = true;
2968                for (int i = 0; i < count; i++) {
2969                    boolean oneIsValid = getItem(i).validate();
2970                    isValid = isValid && oneIsValid;
2971                }
2972                notifyDataSetChanged();
2973                return isValid;
2974            }
2975
2976            public boolean execute() {
2977                return execute("execute");
2978            }
2979
2980            public boolean execute(int actionPosition) {
2981                return execute(actionsAdapter.getItem(actionPosition).first);
2982            }
2983
2984            public boolean execute(String action) {
2985                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2986
2987                if (response == null) return true;
2988                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2989                if (command == null) return true;
2990                String status = command.getAttribute("status");
2991                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2992
2993                if (actionToWebview != null && !action.equals("cancel")) {
2994                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2995                    return false;
2996                }
2997
2998                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2999                packet.setTo(response.getFrom());
3000                final Element c = packet.addChild("command", Namespace.COMMANDS);
3001                c.setAttribute("node", mNode);
3002                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3003
3004                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3005                if (!action.equals("cancel") &&
3006                    !action.equals("prev") &&
3007                    responseElement != null &&
3008                    responseElement.getName().equals("x") &&
3009                    responseElement.getNamespace().equals("jabber:x:data") &&
3010                    formType != null && formType.equals("form")) {
3011
3012                    Data form = Data.parse(responseElement);
3013                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3014                    if (actionList != null) {
3015                        actionList.setValue(action);
3016                        c.setAttribute("action", "execute");
3017                    }
3018
3019                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3020                        if (form.getValue("gateway-jid") == null) {
3021                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3022                        } else {
3023                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3024                        }
3025                    }
3026
3027                    responseElement.setAttribute("type", "submit");
3028                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3029                    if (rsm != null) {
3030                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3031                        max.setContent("1000");
3032                        rsm.addChild(max);
3033                    }
3034
3035                    c.addChild(responseElement);
3036                }
3037
3038                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3039
3040                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3041                    updateWithResponse(iq);
3042                });
3043
3044                loading();
3045                return false;
3046            }
3047
3048            public void refresh() {
3049                notifyDataSetChanged();
3050            }
3051
3052            protected void loading() {
3053                View v = getView();
3054                loadingTimer.schedule(new TimerTask() {
3055                    @Override
3056                    public void run() {
3057                        View v2 = getView();
3058                        loading = true;
3059
3060                        if (v == null && v2 == null) return;
3061                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3062                    }
3063                }, 500);
3064            }
3065
3066            protected GridLayoutManager setupLayoutManager() {
3067                int spanCount = 1;
3068
3069                Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3070                if (reported != null) {
3071                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3072                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3073                    float tableHeaderWidth = reported.stream().reduce(
3074                        0f,
3075                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3076                        (a, b) -> a + b
3077                    );
3078
3079                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3080                }
3081
3082                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3083                    items.clear();
3084                    notifyDataSetChanged();
3085                }
3086
3087                layoutManager = new GridLayoutManager(ctx, spanCount);
3088                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3089                    @Override
3090                    public int getSpanSize(int position) {
3091                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3092                        return 1;
3093                    }
3094                });
3095                return layoutManager;
3096            }
3097
3098            protected void setBinding(CommandPageBinding b) {
3099                mBinding = b;
3100                // https://stackoverflow.com/a/32350474/8611
3101                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3102                    @Override
3103                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3104                        if(rv.getChildCount() > 0) {
3105                            int[] location = new int[2];
3106                            rv.getLocationOnScreen(location);
3107                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3108                            if (childView instanceof ViewGroup) {
3109                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3110                            }
3111                            int action = e.getAction();
3112                            switch (action) {
3113                                case MotionEvent.ACTION_DOWN:
3114                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3115                                        rv.requestDisallowInterceptTouchEvent(true);
3116                                    }
3117                                case MotionEvent.ACTION_UP:
3118                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3119                                        rv.requestDisallowInterceptTouchEvent(true);
3120                                    }
3121                            }
3122                        }
3123
3124                        return false;
3125                    }
3126
3127                    @Override
3128                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3129
3130                    @Override
3131                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3132                });
3133                mBinding.form.setLayoutManager(setupLayoutManager());
3134                mBinding.form.setAdapter(this);
3135                mBinding.actions.setAdapter(actionsAdapter);
3136                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3137                    if (execute(pos)) {
3138                        removeSession(CommandSession.this);
3139                    }
3140                });
3141
3142                actionsAdapter.notifyDataSetChanged();
3143
3144                if (pendingResponsePacket != null) {
3145                    final IqPacket pending = pendingResponsePacket;
3146                    pendingResponsePacket = null;
3147                    updateWithResponseUiThread(pending);
3148                }
3149            }
3150
3151            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3152                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3153                setBinding(binding);
3154                return binding.getRoot();
3155            }
3156
3157            // https://stackoverflow.com/a/36037991/8611
3158            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3159                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3160                    View child = viewGroup.getChildAt(i);
3161                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3162                        View foundView = findViewAt((ViewGroup) child, x, y);
3163                        if (foundView != null && foundView.isShown()) {
3164                            return foundView;
3165                        }
3166                    } else {
3167                        int[] location = new int[2];
3168                        child.getLocationOnScreen(location);
3169                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3170                        if (rect.contains((int)x, (int)y)) {
3171                            return child;
3172                        }
3173                    }
3174                }
3175
3176                return null;
3177            }
3178        }
3179    }
3180}