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