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