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