Conversation.java

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