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