Conversation.java

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