Conversation.java

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