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