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