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