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