Conversation.java

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