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        protected View page1 = null;
1239        protected View page2 = null;
1240
1241        public void setupViewPager(ViewPager pager, TabLayout tabs) {
1242            mPager = pager;
1243            mTabs = tabs;
1244
1245            if (mPager == null) return;
1246            if (sessions != null) show();
1247
1248            page1 = pager.getChildAt(0) == null ? page1 : pager.getChildAt(0);
1249            page2 = pager.getChildAt(1) == null ? page2 : pager.getChildAt(1);
1250            pager.setAdapter(this);
1251            tabs.setupWithViewPager(mPager);
1252            pager.setCurrentItem(getCurrentTab());
1253
1254            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1255                public void onPageScrollStateChanged(int state) { }
1256                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1257
1258                public void onPageSelected(int position) {
1259                    setCurrentTab(position);
1260                }
1261            });
1262        }
1263
1264        public void show() {
1265            if (sessions == null) {
1266                sessions = new ArrayList<>();
1267                notifyDataSetChanged();
1268            }
1269            if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1270        }
1271
1272        public void hide() {
1273            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1274            if (mPager != null) mPager.setCurrentItem(0);
1275            if (mTabs != null) mTabs.setVisibility(View.GONE);
1276            sessions = null;
1277            notifyDataSetChanged();
1278        }
1279
1280        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1281            show();
1282            CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1283
1284            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1285            packet.setTo(command.getAttributeAsJid("jid"));
1286            final Element c = packet.addChild("command", Namespace.COMMANDS);
1287            c.setAttribute("node", command.getAttribute("node"));
1288            c.setAttribute("action", "execute");
1289            View v = mPager;
1290            xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1291                v.post(() -> {
1292                    session.updateWithResponse(iq);
1293                });
1294            });
1295
1296            sessions.add(session);
1297            notifyDataSetChanged();
1298            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1299        }
1300
1301        public void removeSession(CommandSession session) {
1302            sessions.remove(session);
1303            notifyDataSetChanged();
1304        }
1305
1306        @NonNull
1307        @Override
1308        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1309            if (position == 0) {
1310                if (page1.getParent() == null) container.addView(page1);
1311                return page1;
1312            }
1313            if (position == 1) {
1314                if (page2.getParent() == null) container.addView(page2);
1315                return page2;
1316            }
1317
1318            CommandSession session = sessions.get(position-2);
1319            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1320            container.addView(binding.getRoot());
1321            session.setBinding(binding);
1322            return session;
1323        }
1324
1325        @Override
1326        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1327            if (position < 2) return;
1328
1329            container.removeView(((CommandSession) o).getView());
1330        }
1331
1332        @Override
1333        public int getItemPosition(Object o) {
1334            if (mPager != null) {
1335                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1336                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1337            }
1338
1339            int pos = sessions == null ? -1 : sessions.indexOf(o);
1340            if (pos < 0) return PagerAdapter.POSITION_NONE;
1341            return pos + 2;
1342        }
1343
1344        @Override
1345        public int getCount() {
1346            if (sessions == null) return 1;
1347
1348            int count = 2 + sessions.size();
1349            if (mTabs == null) return count;
1350
1351            if (count > 2) {
1352                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1353            } else {
1354                mTabs.setTabMode(TabLayout.MODE_FIXED);
1355            }
1356            return count;
1357        }
1358
1359        @Override
1360        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1361            if (view == o) return true;
1362
1363            if (o instanceof CommandSession) {
1364                return ((CommandSession) o).getView() == view;
1365            }
1366
1367            return false;
1368        }
1369
1370        @Nullable
1371        @Override
1372        public CharSequence getPageTitle(int position) {
1373            switch (position) {
1374                case 0:
1375                    return "Conversation";
1376                case 1:
1377                    return "Commands";
1378                default:
1379                    CommandSession session = sessions.get(position-2);
1380                    if (session == null) return super.getPageTitle(position);
1381                    return session.getTitle();
1382            }
1383        }
1384
1385        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1386            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1387                protected T binding;
1388
1389                public ViewHolder(T binding) {
1390                    super(binding.getRoot());
1391                    this.binding = binding;
1392                }
1393
1394                abstract public void bind(Item el);
1395
1396                protected void setTextOrHide(TextView v, Optional<String> s) {
1397                    if (s == null || !s.isPresent()) {
1398                        v.setVisibility(View.GONE);
1399                    } else {
1400                        v.setVisibility(View.VISIBLE);
1401                        v.setText(s.get());
1402                    }
1403                }
1404
1405                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1406                    int flags = 0;
1407                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1408                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1409
1410                    String type = field.getAttribute("type");
1411                    if (type != null) {
1412                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1413                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1414                        }
1415
1416                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1417
1418                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1419                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1420                        }
1421
1422                        if (type.equals("text-private")) {
1423                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1424                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1425                        }
1426                    }
1427
1428                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1429                    if (validate == null) return;
1430                    String datatype = validate.getAttribute("datatype");
1431                    if (datatype == null) return;
1432
1433                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1434                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1435                    }
1436
1437                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1438                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1439                    }
1440
1441                    if (datatype.equals("xs:date")) {
1442                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1443                    }
1444
1445                    if (datatype.equals("xs:dateTime")) {
1446                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1447                    }
1448
1449                    if (datatype.equals("xs:time")) {
1450                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1451                    }
1452
1453                    if (datatype.equals("xs:anyURI")) {
1454                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1455                    }
1456
1457                    if (datatype.equals("html:tel")) {
1458                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1459                    }
1460
1461                    if (datatype.equals("html:email")) {
1462                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1463                    }
1464                }
1465            }
1466
1467            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1468                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1469
1470                @Override
1471                public void bind(Item iq) {
1472                    binding.errorIcon.setVisibility(View.VISIBLE);
1473
1474                    Element error = iq.el.findChild("error");
1475                    if (error == null) return;
1476                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1477                    if (text == null || text.equals("")) {
1478                        text = error.getChildren().get(0).getName();
1479                    }
1480                    binding.message.setText(text);
1481                }
1482            }
1483
1484            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1485                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1486
1487                @Override
1488                public void bind(Item note) {
1489                    binding.message.setText(note.el.getContent());
1490
1491                    String type = note.el.getAttribute("type");
1492                    if (type != null && type.equals("error")) {
1493                        binding.errorIcon.setVisibility(View.VISIBLE);
1494                    }
1495                }
1496            }
1497
1498            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1499                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1500
1501                @Override
1502                public void bind(Item item) {
1503                    Field field = (Field) item;
1504                    setTextOrHide(binding.label, field.getLabel());
1505                    setTextOrHide(binding.desc, field.getDesc());
1506
1507                    ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1508                    for (Element el : field.el.getChildren()) {
1509                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1510                            values.add(el.getContent());
1511                        }
1512                    }
1513                    binding.values.setAdapter(values);
1514
1515                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1516                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1517                            new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1518                        });
1519                    }
1520
1521                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1522                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1523                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1524                        }
1525                        return true;
1526                    });
1527                }
1528            }
1529
1530            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1531                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1532
1533                @Override
1534                public void bind(Item item) {
1535                    Cell cell = (Cell) item;
1536
1537                    if (cell.el == null) {
1538                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1539                        setTextOrHide(binding.text, cell.reported.getLabel());
1540                    } else {
1541                        SpannableStringBuilder text = new SpannableStringBuilder(cell.el.findChildContent("value", "jabber:x:data"));
1542                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1543                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1544                        }
1545
1546                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1547                        binding.text.setText(text);
1548
1549                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1550                        method.setOnLinkLongClickListener((tv, url) -> {
1551                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1552                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1553                            return true;
1554                        });
1555                        binding.text.setMovementMethod(method);
1556                    }
1557                }
1558            }
1559
1560            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1561                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1562                    super(binding);
1563                    binding.row.setOnClickListener((v) -> {
1564                        binding.checkbox.toggle();
1565                    });
1566                    binding.checkbox.setOnCheckedChangeListener(this);
1567                }
1568                protected Element mValue = null;
1569
1570                @Override
1571                public void bind(Item item) {
1572                    Field field = (Field) item;
1573                    binding.label.setText(field.getLabel().or(""));
1574                    setTextOrHide(binding.desc, field.getDesc());
1575                    mValue = field.getValue();
1576                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1577                }
1578
1579                @Override
1580                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1581                    if (mValue == null) return;
1582
1583                    mValue.setContent(isChecked ? "true" : "false");
1584                }
1585            }
1586
1587            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1588                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1589                    super(binding);
1590                    binding.search.addTextChangedListener(this);
1591                }
1592                protected Element mValue = null;
1593                List<Option> options = new ArrayList<>();
1594                protected ArrayAdapter<Option> adapter;
1595                protected boolean open;
1596
1597                @Override
1598                public void bind(Item item) {
1599                    Field field = (Field) item;
1600                    setTextOrHide(binding.label, field.getLabel());
1601                    setTextOrHide(binding.desc, field.getDesc());
1602
1603                    if (field.error != null) {
1604                        binding.desc.setVisibility(View.VISIBLE);
1605                        binding.desc.setText(field.error);
1606                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1607                    } else {
1608                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1609                    }
1610
1611                    mValue = field.getValue();
1612
1613                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1614                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1615                    setupInputType(field.el, binding.search, null);
1616
1617                    options = field.getOptions();
1618                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1619                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1620                        if (open) binding.search.setText(mValue.getContent());
1621                    });
1622                    search("");
1623                }
1624
1625                @Override
1626                public void afterTextChanged(Editable s) {
1627                    if (open) mValue.setContent(s.toString());
1628                    search(s.toString());
1629                }
1630
1631                @Override
1632                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1633
1634                @Override
1635                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1636
1637                protected void search(String s) {
1638                    List<Option> filteredOptions;
1639                    final String q = s.replaceAll("\\W", "").toLowerCase();
1640                    if (q == null || q.equals("")) {
1641                        filteredOptions = options;
1642                    } else {
1643                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1644                    }
1645                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1646                    binding.list.setAdapter(adapter);
1647
1648                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1649                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1650                }
1651            }
1652
1653            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1654                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1655                    super(binding);
1656                    binding.open.addTextChangedListener(this);
1657                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1658                        @Override
1659                        public View getView(int position, View convertView, ViewGroup parent) {
1660                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1661                            v.setId(position);
1662                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1663                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1664                            return v;
1665                        }
1666                    };
1667                }
1668                protected Element mValue = null;
1669                protected ArrayAdapter<Option> options;
1670
1671                @Override
1672                public void bind(Item item) {
1673                    Field field = (Field) item;
1674                    setTextOrHide(binding.label, field.getLabel());
1675                    setTextOrHide(binding.desc, field.getDesc());
1676
1677                    if (field.error != null) {
1678                        binding.desc.setVisibility(View.VISIBLE);
1679                        binding.desc.setText(field.error);
1680                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1681                    } else {
1682                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1683                    }
1684
1685                    mValue = field.getValue();
1686
1687                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1688                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1689                    binding.open.setText(mValue.getContent());
1690                    setupInputType(field.el, binding.open, null);
1691
1692                    options.clear();
1693                    List<Option> theOptions = field.getOptions();
1694                    options.addAll(theOptions);
1695
1696                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1697                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1698                    float maxColumnWidth = theOptions.stream().map((x) ->
1699                        StaticLayout.getDesiredWidth(x.toString(), paint)
1700                    ).max(Float::compare).orElse(new Float(0.0));
1701                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1702                        binding.radios.setNumColumns(theOptions.size());
1703                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1704                        binding.radios.setNumColumns(theOptions.size() / 2);
1705                    } else {
1706                        binding.radios.setNumColumns(1);
1707                    }
1708                    binding.radios.setAdapter(options);
1709                }
1710
1711                @Override
1712                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1713                    if (mValue == null) return;
1714
1715                    if (isChecked) {
1716                        mValue.setContent(options.getItem(radio.getId()).getValue());
1717                        binding.open.setText(mValue.getContent());
1718                    }
1719                    options.notifyDataSetChanged();
1720                }
1721
1722                @Override
1723                public void afterTextChanged(Editable s) {
1724                    if (mValue == null) return;
1725
1726                    mValue.setContent(s.toString());
1727                    options.notifyDataSetChanged();
1728                }
1729
1730                @Override
1731                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1732
1733                @Override
1734                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1735            }
1736
1737            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1738                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1739                    super(binding);
1740                    binding.spinner.setOnItemSelectedListener(this);
1741                }
1742                protected Element mValue = null;
1743
1744                @Override
1745                public void bind(Item item) {
1746                    Field field = (Field) item;
1747                    setTextOrHide(binding.label, field.getLabel());
1748                    binding.spinner.setPrompt(field.getLabel().or(""));
1749                    setTextOrHide(binding.desc, field.getDesc());
1750
1751                    mValue = field.getValue();
1752
1753                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1754                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1755                    options.addAll(field.getOptions());
1756
1757                    binding.spinner.setAdapter(options);
1758                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1759                }
1760
1761                @Override
1762                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1763                    Option o = (Option) parent.getItemAtPosition(pos);
1764                    if (mValue == null) return;
1765
1766                    mValue.setContent(o == null ? "" : o.getValue());
1767                }
1768
1769                @Override
1770                public void onNothingSelected(AdapterView<?> parent) {
1771                    mValue.setContent("");
1772                }
1773            }
1774
1775            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1776                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1777                    super(binding);
1778                    binding.textinput.addTextChangedListener(this);
1779                }
1780                protected Element mValue = null;
1781
1782                @Override
1783                public void bind(Item item) {
1784                    Field field = (Field) item;
1785                    binding.textinputLayout.setHint(field.getLabel().or(""));
1786
1787                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1788                    for (String desc : field.getDesc().asSet()) {
1789                        binding.textinputLayout.setHelperText(desc);
1790                    }
1791
1792                    binding.textinputLayout.setErrorEnabled(field.error != null);
1793                    if (field.error != null) binding.textinputLayout.setError(field.error);
1794
1795                    mValue = field.getValue();
1796                    binding.textinput.setText(mValue.getContent());
1797                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
1798                }
1799
1800                @Override
1801                public void afterTextChanged(Editable s) {
1802                    if (mValue == null) return;
1803
1804                    mValue.setContent(s.toString());
1805                }
1806
1807                @Override
1808                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1809
1810                @Override
1811                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1812            }
1813
1814            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1815                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1816                protected String boundUrl = "";
1817
1818                @Override
1819                public void bind(Item oob) {
1820                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1821                    binding.webview.getSettings().setJavaScriptEnabled(true);
1822                    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");
1823                    binding.webview.getSettings().setDatabaseEnabled(true);
1824                    binding.webview.getSettings().setDomStorageEnabled(true);
1825                    binding.webview.setWebChromeClient(new WebChromeClient() {
1826                        @Override
1827                        public void onProgressChanged(WebView view, int newProgress) {
1828                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1829                            binding.progressbar.setProgress(newProgress);
1830                        }
1831                    });
1832                    binding.webview.setWebViewClient(new WebViewClient() {
1833                        @Override
1834                        public void onPageFinished(WebView view, String url) {
1835                            super.onPageFinished(view, url);
1836                            mTitle = view.getTitle();
1837                            ConversationPagerAdapter.this.notifyDataSetChanged();
1838                        }
1839                    });
1840                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
1841                    if (!boundUrl.equals(url)) {
1842                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1843                        binding.webview.loadUrl(url);
1844                        boundUrl = url;
1845                    }
1846                }
1847
1848                class JsObject {
1849                    @JavascriptInterface
1850                    public void execute() { execute("execute"); }
1851
1852                    @JavascriptInterface
1853                    public void execute(String action) {
1854                        getView().post(() -> {
1855                            actionToWebview = null;
1856                            if(CommandSession.this.execute(action)) {
1857                                removeSession(CommandSession.this);
1858                            }
1859                        });
1860                    }
1861
1862                    @JavascriptInterface
1863                    public void preventDefault() {
1864                        actionToWebview = binding.webview;
1865                    }
1866                }
1867            }
1868
1869            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1870                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1871
1872                @Override
1873                public void bind(Item item) { }
1874            }
1875
1876            class Item {
1877                protected Element el;
1878                protected int viewType;
1879                protected String error = null;
1880
1881                Item(Element el, int viewType) {
1882                    this.el = el;
1883                    this.viewType = viewType;
1884                }
1885
1886                public boolean validate() {
1887                    error = null;
1888                    return true;
1889                }
1890            }
1891
1892            class Field extends Item {
1893                Field(Element el, int viewType) { super(el, viewType); }
1894
1895                @Override
1896                public boolean validate() {
1897                    if (!super.validate()) return false;
1898                    if (el.findChild("required", "jabber:x:data") == null) return true;
1899                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1900
1901                    error = "this value is required";
1902                    return false;
1903                }
1904
1905                public String getVar() {
1906                    return el.getAttribute("var");
1907                }
1908
1909                public Optional<String> getType() {
1910                    return Optional.fromNullable(el.getAttribute("type"));
1911                }
1912
1913                public Optional<String> getLabel() {
1914                    String label = el.getAttribute("label");
1915                    if (label == null) label = getVar();
1916                    return Optional.fromNullable(label);
1917                }
1918
1919                public Optional<String> getDesc() {
1920                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1921                }
1922
1923                public Element getValue() {
1924                    Element value = el.findChild("value", "jabber:x:data");
1925                    if (value == null) {
1926                        value = el.addChild("value", "jabber:x:data");
1927                    }
1928                    return value;
1929                }
1930
1931                public List<Option> getOptions() {
1932                    return Option.forField(el);
1933                }
1934            }
1935
1936            class Cell extends Item {
1937                protected Field reported;
1938
1939                Cell(Field reported, Element item) {
1940                    super(item, TYPE_RESULT_CELL);
1941                    this.reported = reported;
1942                }
1943            }
1944
1945            protected Field mkField(Element el) {
1946                int viewType = -1;
1947
1948                String formType = responseElement.getAttribute("type");
1949                if (formType != null) {
1950                    String fieldType = el.getAttribute("type");
1951                    if (fieldType == null) fieldType = "text-single";
1952
1953                    if (formType.equals("result") || fieldType.equals("fixed")) {
1954                        viewType = TYPE_RESULT_FIELD;
1955                    } else if (formType.equals("form")) {
1956                        if (fieldType.equals("boolean")) {
1957                            viewType = TYPE_CHECKBOX_FIELD;
1958                        } else if (fieldType.equals("list-single")) {
1959                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1960                            if (Option.forField(el).size() > 9) {
1961                                viewType = TYPE_SEARCH_LIST_FIELD;
1962                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1963                                viewType = TYPE_RADIO_EDIT_FIELD;
1964                            } else {
1965                                viewType = TYPE_SPINNER_FIELD;
1966                            }
1967                        } else {
1968                            viewType = TYPE_TEXT_FIELD;
1969                        }
1970                    }
1971
1972                    return new Field(el, viewType);
1973                }
1974
1975                return null;
1976            }
1977
1978            protected Item mkItem(Element el, int pos) {
1979                int viewType = -1;
1980
1981                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
1982                    if (el.getName().equals("note")) {
1983                        viewType = TYPE_NOTE;
1984                    } else if (el.getNamespace().equals("jabber:x:oob")) {
1985                        viewType = TYPE_WEB;
1986                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
1987                        viewType = TYPE_NOTE;
1988                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
1989                        Field field = mkField(el);
1990                        if (field != null) {
1991                            items.put(pos, field);
1992                            return field;
1993                        }
1994                    }
1995                } else if (response != null) {
1996                    viewType = TYPE_ERROR;
1997                }
1998
1999                Item item = new Item(el, viewType);
2000                items.put(pos, item);
2001                return item;
2002            }
2003
2004            final int TYPE_ERROR = 1;
2005            final int TYPE_NOTE = 2;
2006            final int TYPE_WEB = 3;
2007            final int TYPE_RESULT_FIELD = 4;
2008            final int TYPE_TEXT_FIELD = 5;
2009            final int TYPE_CHECKBOX_FIELD = 6;
2010            final int TYPE_SPINNER_FIELD = 7;
2011            final int TYPE_RADIO_EDIT_FIELD = 8;
2012            final int TYPE_RESULT_CELL = 9;
2013            final int TYPE_PROGRESSBAR = 10;
2014            final int TYPE_SEARCH_LIST_FIELD = 11;
2015
2016            protected boolean loading = false;
2017            protected Timer loadingTimer = new Timer();
2018            protected String mTitle;
2019            protected CommandPageBinding mBinding = null;
2020            protected IqPacket response = null;
2021            protected Element responseElement = null;
2022            protected List<Field> reported = null;
2023            protected SparseArray<Item> items = new SparseArray<>();
2024            protected XmppConnectionService xmppConnectionService;
2025            protected ArrayAdapter<String> actionsAdapter;
2026            protected GridLayoutManager layoutManager;
2027            protected WebView actionToWebview = null;
2028
2029            CommandSession(String title, XmppConnectionService xmppConnectionService) {
2030                loading();
2031                mTitle = title;
2032                this.xmppConnectionService = xmppConnectionService;
2033                if (mPager != null) setupLayoutManager();
2034                actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2035                    @Override
2036                    public View getView(int position, View convertView, ViewGroup parent) {
2037                        View v = super.getView(position, convertView, parent);
2038                        TextView tv = (TextView) v.findViewById(android.R.id.text1);
2039                        tv.setGravity(Gravity.CENTER);
2040                        int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2041                        if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2042                        tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2043                        tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2044                        return v;
2045                    }
2046                };
2047                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2048                    @Override
2049                    public void onChanged() {
2050                        if (mBinding == null) return;
2051
2052                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2053                    }
2054
2055                    @Override
2056                    public void onInvalidated() {}
2057                });
2058            }
2059
2060            public String getTitle() {
2061                return mTitle;
2062            }
2063
2064            public void updateWithResponse(IqPacket iq) {
2065                this.loadingTimer.cancel();
2066                this.loadingTimer = new Timer();
2067                this.loading = false;
2068                this.responseElement = null;
2069                this.reported = null;
2070                this.response = iq;
2071                this.items.clear();
2072                this.actionsAdapter.clear();
2073                layoutManager.setSpanCount(1);
2074
2075                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2076                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2077                    for (Element el : command.getChildren()) {
2078                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2079                            for (Element action : el.getChildren()) {
2080                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2081                                if (action.getName().equals("execute")) continue;
2082
2083                                actionsAdapter.add(action.getName());
2084                            }
2085                        }
2086                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2087                            String title = el.findChildContent("title", "jabber:x:data");
2088                            if (title != null) {
2089                                mTitle = title;
2090                                ConversationPagerAdapter.this.notifyDataSetChanged();
2091                            }
2092
2093                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2094                                this.responseElement = el;
2095                                setupReported(el.findChild("reported", "jabber:x:data"));
2096                                layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2097                            }
2098                            break;
2099                        }
2100                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2101                            String url = el.findChildContent("url", "jabber:x:oob");
2102                            if (url != null) {
2103                                String scheme = Uri.parse(url).getScheme();
2104                                if (scheme.equals("http") || scheme.equals("https")) {
2105                                    this.responseElement = el;
2106                                    break;
2107                                }
2108                            }
2109                        }
2110                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2111                            this.responseElement = el;
2112                            break;
2113                        }
2114                    }
2115
2116                    if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2117                        removeSession(this);
2118                        return;
2119                    }
2120
2121                    if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2122                        // No actions have been given, but we are not done?
2123                        // This is probably a spec violation, but we should do *something*
2124                        actionsAdapter.add("execute");
2125                    }
2126
2127                    if (!actionsAdapter.isEmpty()) {
2128                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2129                            actionsAdapter.add("close");
2130                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2131                            actionsAdapter.insert("cancel", 0);
2132                        }
2133                    }
2134                }
2135
2136                if (actionsAdapter.isEmpty()) {
2137                    actionsAdapter.add("close");
2138                }
2139
2140                notifyDataSetChanged();
2141            }
2142
2143            protected void setupReported(Element el) {
2144                if (el == null) {
2145                    reported = null;
2146                    return;
2147                }
2148
2149                reported = new ArrayList<>();
2150                for (Element fieldEl : el.getChildren()) {
2151                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2152                    reported.add(mkField(fieldEl));
2153                }
2154            }
2155
2156            @Override
2157            public int getItemCount() {
2158                if (loading) return 1;
2159                if (response == null) return 0;
2160                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2161                    int i = 0;
2162                    for (Element el : responseElement.getChildren()) {
2163                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2164                        if (el.getName().equals("title")) continue;
2165                        if (el.getName().equals("field")) {
2166                            String type = el.getAttribute("type");
2167                            if (type != null && type.equals("hidden")) continue;
2168                        }
2169
2170                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2171                            if (reported != null) i += reported.size();
2172                            continue;
2173                        }
2174
2175                        i++;
2176                    }
2177                    return i;
2178                }
2179                return 1;
2180            }
2181
2182            public Item getItem(int position) {
2183                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2184                if (items.get(position) != null) return items.get(position);
2185                if (response == null) return null;
2186
2187                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2188                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2189                        int i = 0;
2190                        for (Element el : responseElement.getChildren()) {
2191                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2192                            if (el.getName().equals("title")) continue;
2193                            if (el.getName().equals("field")) {
2194                                String type = el.getAttribute("type");
2195                                if (type != null && type.equals("hidden")) continue;
2196                            }
2197
2198                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2199                                Cell cell = null;
2200
2201                                if (reported != null) {
2202                                    if (reported.size() > position - i) {
2203                                        Field reportedField = reported.get(position - i);
2204                                        Element itemField = null;
2205                                        if (el.getName().equals("item")) {
2206                                            for (Element subel : el.getChildren()) {
2207                                                if (subel.getAttribute("var").equals(reportedField.getVar())) {
2208                                                   itemField = subel;
2209                                                   break;
2210                                                }
2211                                            }
2212                                        }
2213                                        cell = new Cell(reportedField, itemField);
2214                                    } else {
2215                                        i += reported.size();
2216                                        continue;
2217                                    }
2218                                }
2219
2220                                if (cell != null) {
2221                                    items.put(position, cell);
2222                                    return cell;
2223                                }
2224                            }
2225
2226                            if (i < position) {
2227                                i++;
2228                                continue;
2229                            }
2230
2231                            return mkItem(el, position);
2232                        }
2233                    }
2234                }
2235
2236                return mkItem(responseElement == null ? response : responseElement, position);
2237            }
2238
2239            @Override
2240            public int getItemViewType(int position) {
2241                return getItem(position).viewType;
2242            }
2243
2244            @Override
2245            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2246                switch(viewType) {
2247                    case TYPE_ERROR: {
2248                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2249                        return new ErrorViewHolder(binding);
2250                    }
2251                    case TYPE_NOTE: {
2252                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2253                        return new NoteViewHolder(binding);
2254                    }
2255                    case TYPE_WEB: {
2256                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2257                        return new WebViewHolder(binding);
2258                    }
2259                    case TYPE_RESULT_FIELD: {
2260                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2261                        return new ResultFieldViewHolder(binding);
2262                    }
2263                    case TYPE_RESULT_CELL: {
2264                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2265                        return new ResultCellViewHolder(binding);
2266                    }
2267                    case TYPE_CHECKBOX_FIELD: {
2268                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2269                        return new CheckboxFieldViewHolder(binding);
2270                    }
2271                    case TYPE_SEARCH_LIST_FIELD: {
2272                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2273                        return new SearchListFieldViewHolder(binding);
2274                    }
2275                    case TYPE_RADIO_EDIT_FIELD: {
2276                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2277                        return new RadioEditFieldViewHolder(binding);
2278                    }
2279                    case TYPE_SPINNER_FIELD: {
2280                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2281                        return new SpinnerFieldViewHolder(binding);
2282                    }
2283                    case TYPE_TEXT_FIELD: {
2284                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2285                        return new TextFieldViewHolder(binding);
2286                    }
2287                    case TYPE_PROGRESSBAR: {
2288                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2289                        return new ProgressBarViewHolder(binding);
2290                    }
2291                    default:
2292                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2293                }
2294            }
2295
2296            @Override
2297            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2298                viewHolder.bind(getItem(position));
2299            }
2300
2301            public View getView() {
2302                return mBinding.getRoot();
2303            }
2304
2305            public boolean validate() {
2306                int count = getItemCount();
2307                boolean isValid = true;
2308                for (int i = 0; i < count; i++) {
2309                    boolean oneIsValid = getItem(i).validate();
2310                    isValid = isValid && oneIsValid;
2311                }
2312                notifyDataSetChanged();
2313                return isValid;
2314            }
2315
2316            public boolean execute() {
2317                return execute("execute");
2318            }
2319
2320            public boolean execute(int actionPosition) {
2321                return execute(actionsAdapter.getItem(actionPosition));
2322            }
2323
2324            public boolean execute(String action) {
2325                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2326
2327                if (response == null) return true;
2328                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2329                if (command == null) return true;
2330                String status = command.getAttribute("status");
2331                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2332
2333                if (actionToWebview != null) {
2334                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2335                    return false;
2336                }
2337
2338                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2339                packet.setTo(response.getFrom());
2340                final Element c = packet.addChild("command", Namespace.COMMANDS);
2341                c.setAttribute("node", command.getAttribute("node"));
2342                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2343                c.setAttribute("action", action);
2344
2345                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2346                if (!action.equals("cancel") &&
2347                    !action.equals("prev") &&
2348                    responseElement != null &&
2349                    responseElement.getName().equals("x") &&
2350                    responseElement.getNamespace().equals("jabber:x:data") &&
2351                    formType != null && formType.equals("form")) {
2352
2353                    responseElement.setAttribute("type", "submit");
2354                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2355                    if (rsm != null) {
2356                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2357                        max.setContent("1000");
2358                        rsm.addChild(max);
2359                    }
2360                    c.addChild(responseElement);
2361                }
2362
2363                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2364                    getView().post(() -> {
2365                        updateWithResponse(iq);
2366                    });
2367                });
2368
2369                loading();
2370                return false;
2371            }
2372
2373            protected void loading() {
2374                loadingTimer.schedule(new TimerTask() {
2375                    @Override
2376                    public void run() {
2377                        getView().post(() -> {
2378                            loading = true;
2379                            notifyDataSetChanged();
2380                        });
2381                    }
2382                }, 500);
2383            }
2384
2385            protected GridLayoutManager setupLayoutManager() {
2386                layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2387                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2388                    @Override
2389                    public int getSpanSize(int position) {
2390                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2391                        return 1;
2392                    }
2393                });
2394                return layoutManager;
2395            }
2396
2397            public void setBinding(CommandPageBinding b) {
2398                mBinding = b;
2399                // https://stackoverflow.com/a/32350474/8611
2400                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2401                    @Override
2402                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2403                        if(rv.getChildCount() > 0) {
2404                            int[] location = new int[2];
2405                            rv.getLocationOnScreen(location);
2406                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2407                            if (childView instanceof ViewGroup) {
2408                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2409                            }
2410                            if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2411                                int action = e.getAction();
2412                                switch (action) {
2413                                    case MotionEvent.ACTION_DOWN:
2414                                        rv.requestDisallowInterceptTouchEvent(true);
2415                                }
2416                            }
2417                        }
2418
2419                        return false;
2420                    }
2421
2422                    @Override
2423                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2424
2425                    @Override
2426                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2427                });
2428                mBinding.form.setLayoutManager(setupLayoutManager());
2429                mBinding.form.setAdapter(this);
2430                mBinding.actions.setAdapter(actionsAdapter);
2431                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2432                    if (execute(pos)) {
2433                        removeSession(CommandSession.this);
2434                    }
2435                });
2436
2437                actionsAdapter.notifyDataSetChanged();
2438            }
2439
2440            // https://stackoverflow.com/a/36037991/8611
2441            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2442                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2443                    View child = viewGroup.getChildAt(i);
2444                    if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2445                        View foundView = findViewAt((ViewGroup) child, x, y);
2446                        if (foundView != null && foundView.isShown()) {
2447                            return foundView;
2448                        }
2449                    } else {
2450                        int[] location = new int[2];
2451                        child.getLocationOnScreen(location);
2452                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2453                        if (rect.contains((int)x, (int)y)) {
2454                            return child;
2455                        }
2456                    }
2457                }
2458
2459                return null;
2460            }
2461        }
2462    }
2463}