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