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