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