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                                     if (v.getHeight() == 0) return;
1871                                     icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1872                                     Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
1873                                     Canvas bmcanvas = new Canvas(bitmap);
1874                                     icon.renderToCanvas(bmcanvas);
1875                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
1876                                 });
1877                            }
1878
1879                            return v;
1880                        }
1881                    };
1882                }
1883                protected Element mValue = null;
1884                protected ArrayAdapter<Option> options;
1885                protected Option defaultOption = null;
1886
1887                @Override
1888                public void bind(Item item) {
1889                    Field field = (Field) item;
1890                    setTextOrHide(binding.label, field.getLabel());
1891                    setTextOrHide(binding.desc, field.getDesc());
1892
1893                    if (field.error != null) {
1894                        binding.desc.setVisibility(View.VISIBLE);
1895                        binding.desc.setText(field.error);
1896                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1897                    } else {
1898                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1899                    }
1900
1901                    mValue = field.getValue();
1902
1903                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1904                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1905                    binding.openButton.setOnClickListener((view) -> {
1906                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
1907                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
1908                        builder.setPositiveButton(R.string.action_execute, null);
1909                        if (field.getDesc().isPresent()) {
1910                            dialogBinding.inputLayout.setHint(field.getDesc().get());
1911                        }
1912                        dialogBinding.inputEditText.requestFocus();
1913                        dialogBinding.inputEditText.getText().append(mValue.getContent());
1914                        builder.setView(dialogBinding.getRoot());
1915                        builder.setNegativeButton(R.string.cancel, null);
1916                        final AlertDialog dialog = builder.create();
1917                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
1918                        dialog.show();
1919                        View.OnClickListener clickListener = v -> {
1920                            loading = true;
1921                            String value = dialogBinding.inputEditText.getText().toString();
1922                            mValue.setContent(value);
1923                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1924                            dialog.dismiss();
1925                            execute();
1926                        };
1927                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
1928                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
1929                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1930                            dialog.dismiss();
1931                        }));
1932                        dialog.setCanceledOnTouchOutside(false);
1933                        dialog.setOnDismissListener(dialog1 -> {
1934                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1935                        });
1936                    });
1937
1938                    options.clear();
1939                    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();
1940
1941                    defaultOption = null;
1942                    for (Option option : theOptions) {
1943                        if (option.getValue().equals(mValue.getContent())) {
1944                            defaultOption = option;
1945                            break;
1946                        }
1947                    }
1948                    if (defaultOption == null && !mValue.getContent().equals("")) {
1949                        // Synthesize default option for custom value
1950                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
1951                    }
1952                    if (defaultOption == null) {
1953                        binding.defaultButton.setVisibility(View.GONE);
1954                    } else {
1955                        theOptions.remove(defaultOption);
1956                        binding.defaultButton.setVisibility(View.VISIBLE);
1957
1958                        final SVG defaultIcon = defaultOption.getIcon();
1959                        if (defaultIcon != null) {
1960                             defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1961                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
1962                             Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
1963                             bitmap.setDensity(display.densityDpi);
1964                             Canvas bmcanvas = new Canvas(bitmap);
1965                             defaultIcon.renderToCanvas(bmcanvas);
1966                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
1967                        }
1968
1969                        binding.defaultButton.setText(defaultOption.toString());
1970                        binding.defaultButton.setOnClickListener((view) -> {
1971                            loading = true;
1972                            mValue.setContent(defaultOption.getValue());
1973                            execute();
1974                        });
1975                    }
1976
1977                    options.addAll(theOptions);
1978                    binding.buttons.setAdapter(options);
1979                }
1980            }
1981
1982            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1983                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1984                    super(binding);
1985                    binding.textinput.addTextChangedListener(this);
1986                }
1987                protected Element mValue = null;
1988
1989                @Override
1990                public void bind(Item item) {
1991                    Field field = (Field) item;
1992                    binding.textinputLayout.setHint(field.getLabel().or(""));
1993
1994                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1995                    for (String desc : field.getDesc().asSet()) {
1996                        binding.textinputLayout.setHelperText(desc);
1997                    }
1998
1999                    binding.textinputLayout.setErrorEnabled(field.error != null);
2000                    if (field.error != null) binding.textinputLayout.setError(field.error);
2001
2002                    mValue = field.getValue();
2003                    binding.textinput.setText(mValue.getContent());
2004                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2005                }
2006
2007                @Override
2008                public void afterTextChanged(Editable s) {
2009                    if (mValue == null) return;
2010
2011                    mValue.setContent(s.toString());
2012                }
2013
2014                @Override
2015                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2016
2017                @Override
2018                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2019            }
2020
2021            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2022                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2023                protected String boundUrl = "";
2024
2025                @Override
2026                public void bind(Item oob) {
2027                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2028                    binding.webview.getSettings().setJavaScriptEnabled(true);
2029                    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");
2030                    binding.webview.getSettings().setDatabaseEnabled(true);
2031                    binding.webview.getSettings().setDomStorageEnabled(true);
2032                    binding.webview.setWebChromeClient(new WebChromeClient() {
2033                        @Override
2034                        public void onProgressChanged(WebView view, int newProgress) {
2035                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2036                            binding.progressbar.setProgress(newProgress);
2037                        }
2038                    });
2039                    binding.webview.setWebViewClient(new WebViewClient() {
2040                        @Override
2041                        public void onPageFinished(WebView view, String url) {
2042                            super.onPageFinished(view, url);
2043                            mTitle = view.getTitle();
2044                            ConversationPagerAdapter.this.notifyDataSetChanged();
2045                        }
2046                    });
2047                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2048                    if (!boundUrl.equals(url)) {
2049                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2050                        binding.webview.loadUrl(url);
2051                        boundUrl = url;
2052                    }
2053                }
2054
2055                class JsObject {
2056                    @JavascriptInterface
2057                    public void execute() { execute("execute"); }
2058
2059                    @JavascriptInterface
2060                    public void execute(String action) {
2061                        getView().post(() -> {
2062                            actionToWebview = null;
2063                            if(CommandSession.this.execute(action)) {
2064                                removeSession(CommandSession.this);
2065                            }
2066                        });
2067                    }
2068
2069                    @JavascriptInterface
2070                    public void preventDefault() {
2071                        actionToWebview = binding.webview;
2072                    }
2073                }
2074            }
2075
2076            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2077                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2078
2079                @Override
2080                public void bind(Item item) { }
2081            }
2082
2083            class Item {
2084                protected Element el;
2085                protected int viewType;
2086                protected String error = null;
2087
2088                Item(Element el, int viewType) {
2089                    this.el = el;
2090                    this.viewType = viewType;
2091                }
2092
2093                public boolean validate() {
2094                    error = null;
2095                    return true;
2096                }
2097            }
2098
2099            class Field extends Item {
2100                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2101
2102                @Override
2103                public boolean validate() {
2104                    if (!super.validate()) return false;
2105                    if (el.findChild("required", "jabber:x:data") == null) return true;
2106                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2107
2108                    error = "this value is required";
2109                    return false;
2110                }
2111
2112                public String getVar() {
2113                    return el.getAttribute("var");
2114                }
2115
2116                public Optional<String> getType() {
2117                    return Optional.fromNullable(el.getAttribute("type"));
2118                }
2119
2120                public Optional<String> getLabel() {
2121                    String label = el.getAttribute("label");
2122                    if (label == null) label = getVar();
2123                    return Optional.fromNullable(label);
2124                }
2125
2126                public Optional<String> getDesc() {
2127                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2128                }
2129
2130                public Element getValue() {
2131                    Element value = el.findChild("value", "jabber:x:data");
2132                    if (value == null) {
2133                        value = el.addChild("value", "jabber:x:data");
2134                    }
2135                    return value;
2136                }
2137
2138                public List<Option> getOptions() {
2139                    return Option.forField(el);
2140                }
2141            }
2142
2143            class Cell extends Item {
2144                protected Field reported;
2145
2146                Cell(Field reported, Element item) {
2147                    super(item, TYPE_RESULT_CELL);
2148                    this.reported = reported;
2149                }
2150            }
2151
2152            protected Field mkField(Element el) {
2153                int viewType = -1;
2154
2155                String formType = responseElement.getAttribute("type");
2156                if (formType != null) {
2157                    String fieldType = el.getAttribute("type");
2158                    if (fieldType == null) fieldType = "text-single";
2159
2160                    if (formType.equals("result") || fieldType.equals("fixed")) {
2161                        viewType = TYPE_RESULT_FIELD;
2162                    } else if (formType.equals("form")) {
2163                        if (fieldType.equals("boolean")) {
2164                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2165                                viewType = TYPE_BUTTON_GRID_FIELD;
2166                            } else {
2167                                viewType = TYPE_CHECKBOX_FIELD;
2168                            }
2169                        } else if (fieldType.equals("list-single")) {
2170                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2171                            if (Option.forField(el).size() > 9) {
2172                                viewType = TYPE_SEARCH_LIST_FIELD;
2173                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2174                                viewType = TYPE_BUTTON_GRID_FIELD;
2175                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2176                                viewType = TYPE_RADIO_EDIT_FIELD;
2177                            } else {
2178                                viewType = TYPE_SPINNER_FIELD;
2179                            }
2180                        } else {
2181                            viewType = TYPE_TEXT_FIELD;
2182                        }
2183                    }
2184
2185                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2186                }
2187
2188                return null;
2189            }
2190
2191            protected Item mkItem(Element el, int pos) {
2192                int viewType = -1;
2193
2194                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2195                    if (el.getName().equals("note")) {
2196                        viewType = TYPE_NOTE;
2197                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2198                        viewType = TYPE_WEB;
2199                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2200                        viewType = TYPE_NOTE;
2201                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2202                        Field field = mkField(el);
2203                        if (field != null) {
2204                            items.put(pos, field);
2205                            return field;
2206                        }
2207                    }
2208                } else if (response != null) {
2209                    viewType = TYPE_ERROR;
2210                }
2211
2212                Item item = new Item(el, viewType);
2213                items.put(pos, item);
2214                return item;
2215            }
2216
2217            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2218                protected Context ctx;
2219
2220                public ActionsAdapter(Context ctx) {
2221                    super(ctx, R.layout.simple_list_item);
2222                    this.ctx = ctx;
2223                }
2224
2225                @Override
2226                public View getView(int position, View convertView, ViewGroup parent) {
2227                    View v = super.getView(position, convertView, parent);
2228                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2229                    tv.setGravity(Gravity.CENTER);
2230                    tv.setText(getItem(position).second);
2231                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2232                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2233                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2234                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2235                    return v;
2236                }
2237
2238                public int getPosition(String s) {
2239                    for(int i = 0; i < getCount(); i++) {
2240                        if (getItem(i).first.equals(s)) return i;
2241                    }
2242                    return -1;
2243                }
2244
2245                public int countExceptCancel() {
2246                    int count = 0;
2247                    for(int i = 0; i < getCount(); i++) {
2248                        if (!getItem(i).first.equals("cancel")) count++;
2249                    }
2250                    return count;
2251                }
2252
2253                public void clearExceptCancel() {
2254                    Pair<String,String> cancelItem = null;
2255                    for(int i = 0; i < getCount(); i++) {
2256                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2257                    }
2258                    clear();
2259                    if (cancelItem != null) add(cancelItem);
2260                }
2261            }
2262
2263            final int TYPE_ERROR = 1;
2264            final int TYPE_NOTE = 2;
2265            final int TYPE_WEB = 3;
2266            final int TYPE_RESULT_FIELD = 4;
2267            final int TYPE_TEXT_FIELD = 5;
2268            final int TYPE_CHECKBOX_FIELD = 6;
2269            final int TYPE_SPINNER_FIELD = 7;
2270            final int TYPE_RADIO_EDIT_FIELD = 8;
2271            final int TYPE_RESULT_CELL = 9;
2272            final int TYPE_PROGRESSBAR = 10;
2273            final int TYPE_SEARCH_LIST_FIELD = 11;
2274            final int TYPE_ITEM_CARD = 12;
2275            final int TYPE_BUTTON_GRID_FIELD = 13;
2276
2277            protected boolean loading = false;
2278            protected Timer loadingTimer = new Timer();
2279            protected String mTitle;
2280            protected String mNode;
2281            protected CommandPageBinding mBinding = null;
2282            protected IqPacket response = null;
2283            protected Element responseElement = null;
2284            protected List<Field> reported = null;
2285            protected SparseArray<Item> items = new SparseArray<>();
2286            protected XmppConnectionService xmppConnectionService;
2287            protected ActionsAdapter actionsAdapter;
2288            protected GridLayoutManager layoutManager;
2289            protected WebView actionToWebview = null;
2290            protected int fillableFieldCount = 0;
2291
2292            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2293                loading();
2294                mTitle = title;
2295                mNode = node;
2296                this.xmppConnectionService = xmppConnectionService;
2297                if (mPager != null) setupLayoutManager();
2298                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2299                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2300                    @Override
2301                    public void onChanged() {
2302                        if (mBinding == null) return;
2303
2304                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2305                    }
2306
2307                    @Override
2308                    public void onInvalidated() {}
2309                });
2310            }
2311
2312            public String getTitle() {
2313                return mTitle;
2314            }
2315
2316            public void updateWithResponse(IqPacket iq) {
2317                this.loadingTimer.cancel();
2318                this.loadingTimer = new Timer();
2319                this.loading = false;
2320                this.responseElement = null;
2321                this.fillableFieldCount = 0;
2322                this.reported = null;
2323                this.response = iq;
2324                this.items.clear();
2325                this.actionsAdapter.clear();
2326                layoutManager.setSpanCount(1);
2327
2328                boolean actionsCleared = false;
2329                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2330                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2331                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2332                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2333                    }
2334
2335                    for (Element el : command.getChildren()) {
2336                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2337                            for (Element action : el.getChildren()) {
2338                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2339                                if (action.getName().equals("execute")) continue;
2340
2341                                actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2342                            }
2343                        }
2344                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2345                            Data form = Data.parse(el);
2346                            String title = form.getTitle();
2347                            if (title != null) {
2348                                mTitle = title;
2349                                ConversationPagerAdapter.this.notifyDataSetChanged();
2350                            }
2351
2352                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2353                                this.responseElement = el;
2354                                setupReported(el.findChild("reported", "jabber:x:data"));
2355                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2356                            }
2357
2358                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2359                            if (actionList != null) {
2360                                actionsAdapter.clear();
2361
2362                                for (Option action : actionList.getOptions()) {
2363                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2364                                }
2365                            }
2366
2367                            String fillableFieldType = null;
2368                            String fillableFieldValue = null;
2369                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2370                                if (field.getType() != null && !field.getType().equals("hidden") && !field.getType().equals("fixed") && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2371                                    fillableFieldType = field.getType();
2372                                    fillableFieldValue = field.getValue();
2373                                    fillableFieldCount++;
2374                                }
2375                            }
2376
2377                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2378                                actionsCleared = true;
2379                                actionsAdapter.clearExceptCancel();
2380                            }
2381                            break;
2382                        }
2383                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2384                            String url = el.findChildContent("url", "jabber:x:oob");
2385                            if (url != null) {
2386                                String scheme = Uri.parse(url).getScheme();
2387                                if (scheme.equals("http") || scheme.equals("https")) {
2388                                    this.responseElement = el;
2389                                    break;
2390                                }
2391                            }
2392                        }
2393                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2394                            this.responseElement = el;
2395                            break;
2396                        }
2397                    }
2398
2399                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2400                        removeSession(this);
2401                        return;
2402                    }
2403
2404                    if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2405                        // No actions have been given, but we are not done?
2406                        // This is probably a spec violation, but we should do *something*
2407                        actionsAdapter.add(Pair.create("execute", "execute"));
2408                    }
2409
2410                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2411                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2412                            actionsAdapter.add(Pair.create("close", "close"));
2413                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2414                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2415                        }
2416                    }
2417                }
2418
2419                if (actionsAdapter.isEmpty()) {
2420                    actionsAdapter.add(Pair.create("close", "close"));
2421                }
2422
2423                notifyDataSetChanged();
2424            }
2425
2426            protected void setupReported(Element el) {
2427                if (el == null) {
2428                    reported = null;
2429                    return;
2430                }
2431
2432                reported = new ArrayList<>();
2433                for (Element fieldEl : el.getChildren()) {
2434                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2435                    reported.add(mkField(fieldEl));
2436                }
2437            }
2438
2439            @Override
2440            public int getItemCount() {
2441                if (loading) return 1;
2442                if (response == null) return 0;
2443                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2444                    int i = 0;
2445                    for (Element el : responseElement.getChildren()) {
2446                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2447                        if (el.getName().equals("title")) continue;
2448                        if (el.getName().equals("field")) {
2449                            String type = el.getAttribute("type");
2450                            if (type != null && type.equals("hidden")) continue;
2451                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2452                        }
2453
2454                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2455                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2456                                if (el.getName().equals("reported")) continue;
2457                                i += 1;
2458                            } else {
2459                                if (reported != null) i += reported.size();
2460                            }
2461                            continue;
2462                        }
2463
2464                        i++;
2465                    }
2466                    return i;
2467                }
2468                return 1;
2469            }
2470
2471            public Item getItem(int position) {
2472                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2473                if (items.get(position) != null) return items.get(position);
2474                if (response == null) return null;
2475
2476                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2477                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2478                        int i = 0;
2479                        for (Element el : responseElement.getChildren()) {
2480                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2481                            if (el.getName().equals("title")) continue;
2482                            if (el.getName().equals("field")) {
2483                                String type = el.getAttribute("type");
2484                                if (type != null && type.equals("hidden")) continue;
2485                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2486                            }
2487
2488                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2489                                Cell cell = null;
2490
2491                                if (reported != null) {
2492                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2493                                        if (el.getName().equals("reported")) continue;
2494                                        if (i == position) {
2495                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2496                                            return items.get(position);
2497                                        }
2498                                    } else {
2499                                        if (reported.size() > position - i) {
2500                                            Field reportedField = reported.get(position - i);
2501                                            Element itemField = null;
2502                                            if (el.getName().equals("item")) {
2503                                                for (Element subel : el.getChildren()) {
2504                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2505                                                       itemField = subel;
2506                                                       break;
2507                                                    }
2508                                                }
2509                                            }
2510                                            cell = new Cell(reportedField, itemField);
2511                                        } else {
2512                                            i += reported.size();
2513                                            continue;
2514                                        }
2515                                    }
2516                                }
2517
2518                                if (cell != null) {
2519                                    items.put(position, cell);
2520                                    return cell;
2521                                }
2522                            }
2523
2524                            if (i < position) {
2525                                i++;
2526                                continue;
2527                            }
2528
2529                            return mkItem(el, position);
2530                        }
2531                    }
2532                }
2533
2534                return mkItem(responseElement == null ? response : responseElement, position);
2535            }
2536
2537            @Override
2538            public int getItemViewType(int position) {
2539                return getItem(position).viewType;
2540            }
2541
2542            @Override
2543            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2544                switch(viewType) {
2545                    case TYPE_ERROR: {
2546                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2547                        return new ErrorViewHolder(binding);
2548                    }
2549                    case TYPE_NOTE: {
2550                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2551                        return new NoteViewHolder(binding);
2552                    }
2553                    case TYPE_WEB: {
2554                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2555                        return new WebViewHolder(binding);
2556                    }
2557                    case TYPE_RESULT_FIELD: {
2558                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2559                        return new ResultFieldViewHolder(binding);
2560                    }
2561                    case TYPE_RESULT_CELL: {
2562                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2563                        return new ResultCellViewHolder(binding);
2564                    }
2565                    case TYPE_ITEM_CARD: {
2566                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2567                        return new ItemCardViewHolder(binding);
2568                    }
2569                    case TYPE_CHECKBOX_FIELD: {
2570                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2571                        return new CheckboxFieldViewHolder(binding);
2572                    }
2573                    case TYPE_SEARCH_LIST_FIELD: {
2574                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2575                        return new SearchListFieldViewHolder(binding);
2576                    }
2577                    case TYPE_RADIO_EDIT_FIELD: {
2578                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2579                        return new RadioEditFieldViewHolder(binding);
2580                    }
2581                    case TYPE_SPINNER_FIELD: {
2582                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2583                        return new SpinnerFieldViewHolder(binding);
2584                    }
2585                    case TYPE_BUTTON_GRID_FIELD: {
2586                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2587                        return new ButtonGridFieldViewHolder(binding);
2588                    }
2589                    case TYPE_TEXT_FIELD: {
2590                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2591                        return new TextFieldViewHolder(binding);
2592                    }
2593                    case TYPE_PROGRESSBAR: {
2594                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2595                        return new ProgressBarViewHolder(binding);
2596                    }
2597                    default:
2598                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2599                }
2600            }
2601
2602            @Override
2603            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2604                viewHolder.bind(getItem(position));
2605            }
2606
2607            public View getView() {
2608                return mBinding.getRoot();
2609            }
2610
2611            public boolean validate() {
2612                int count = getItemCount();
2613                boolean isValid = true;
2614                for (int i = 0; i < count; i++) {
2615                    boolean oneIsValid = getItem(i).validate();
2616                    isValid = isValid && oneIsValid;
2617                }
2618                notifyDataSetChanged();
2619                return isValid;
2620            }
2621
2622            public boolean execute() {
2623                return execute("execute");
2624            }
2625
2626            public boolean execute(int actionPosition) {
2627                return execute(actionsAdapter.getItem(actionPosition).first);
2628            }
2629
2630            public boolean execute(String action) {
2631                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2632
2633                if (response == null) return true;
2634                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2635                if (command == null) return true;
2636                String status = command.getAttribute("status");
2637                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2638
2639                if (actionToWebview != null) {
2640                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2641                    return false;
2642                }
2643
2644                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2645                packet.setTo(response.getFrom());
2646                final Element c = packet.addChild("command", Namespace.COMMANDS);
2647                c.setAttribute("node", mNode);
2648                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2649
2650                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2651                if (!action.equals("cancel") &&
2652                    !action.equals("prev") &&
2653                    responseElement != null &&
2654                    responseElement.getName().equals("x") &&
2655                    responseElement.getNamespace().equals("jabber:x:data") &&
2656                    formType != null && formType.equals("form")) {
2657
2658                    Data form = Data.parse(responseElement);
2659                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2660                    if (actionList != null) {
2661                        actionList.setValue(action);
2662                        c.setAttribute("action", "execute");
2663                    }
2664
2665                    responseElement.setAttribute("type", "submit");
2666                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2667                    if (rsm != null) {
2668                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2669                        max.setContent("1000");
2670                        rsm.addChild(max);
2671                    }
2672
2673                    c.addChild(responseElement);
2674                }
2675
2676                if (c.getAttribute("action") == null) c.setAttribute("action", action);
2677
2678                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2679                    getView().post(() -> {
2680                        updateWithResponse(iq);
2681                    });
2682                });
2683
2684                loading();
2685                return false;
2686            }
2687
2688            protected void loading() {
2689                loadingTimer.schedule(new TimerTask() {
2690                    @Override
2691                    public void run() {
2692                        getView().post(() -> {
2693                            loading = true;
2694                            notifyDataSetChanged();
2695                        });
2696                    }
2697                }, 500);
2698            }
2699
2700            protected GridLayoutManager setupLayoutManager() {
2701                int spanCount = 1;
2702
2703                if (reported != null && mPager != null) {
2704                    float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2705                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2706                    float tableHeaderWidth = reported.stream().reduce(
2707                        0f,
2708                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2709                        (a, b) -> a + b
2710                    );
2711
2712                    spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2713                }
2714
2715                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2716                    items.clear();
2717                    notifyDataSetChanged();
2718                }
2719
2720                layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2721                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2722                    @Override
2723                    public int getSpanSize(int position) {
2724                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2725                        return 1;
2726                    }
2727                });
2728                return layoutManager;
2729            }
2730
2731            public void setBinding(CommandPageBinding b) {
2732                mBinding = b;
2733                // https://stackoverflow.com/a/32350474/8611
2734                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2735                    @Override
2736                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2737                        if(rv.getChildCount() > 0) {
2738                            int[] location = new int[2];
2739                            rv.getLocationOnScreen(location);
2740                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2741                            if (childView instanceof ViewGroup) {
2742                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2743                            }
2744                            int action = e.getAction();
2745                            switch (action) {
2746                                case MotionEvent.ACTION_DOWN:
2747                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
2748                                        rv.requestDisallowInterceptTouchEvent(true);
2749                                    }
2750                                case MotionEvent.ACTION_UP:
2751                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
2752                                        rv.requestDisallowInterceptTouchEvent(true);
2753                                    }
2754                            }
2755                        }
2756
2757                        return false;
2758                    }
2759
2760                    @Override
2761                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2762
2763                    @Override
2764                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2765                });
2766                mBinding.form.setLayoutManager(setupLayoutManager());
2767                mBinding.form.setAdapter(this);
2768                mBinding.actions.setAdapter(actionsAdapter);
2769                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2770                    if (execute(pos)) {
2771                        removeSession(CommandSession.this);
2772                    }
2773                });
2774
2775                actionsAdapter.notifyDataSetChanged();
2776            }
2777
2778            // https://stackoverflow.com/a/36037991/8611
2779            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2780                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2781                    View child = viewGroup.getChildAt(i);
2782                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
2783                        View foundView = findViewAt((ViewGroup) child, x, y);
2784                        if (foundView != null && foundView.isShown()) {
2785                            return foundView;
2786                        }
2787                    } else {
2788                        int[] location = new int[2];
2789                        child.getLocationOnScreen(location);
2790                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2791                        if (rect.contains((int)x, (int)y)) {
2792                            return child;
2793                        }
2794                    }
2795                }
2796
2797                return null;
2798            }
2799        }
2800    }
2801}