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                message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 695                return message;
 696            } else {
 697                return this.messages.get(this.messages.size() - 1);
 698            }
 699        }
 700    }
 701
 702    public @NonNull
 703    CharSequence getName() {
 704        if (getMode() == MODE_MULTI) {
 705            final String roomName = getMucOptions().getName();
 706            final String subject = getMucOptions().getSubject();
 707            final Bookmark bookmark = getBookmark();
 708            final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
 709            if (printableValue(roomName)) {
 710                return roomName;
 711            } else if (printableValue(subject)) {
 712                return subject;
 713            } else if (printableValue(bookmarkName, false)) {
 714                return bookmarkName;
 715            } else {
 716                final String generatedName = getMucOptions().createNameFromParticipants();
 717                if (printableValue(generatedName)) {
 718                    return generatedName;
 719                } else {
 720                    return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
 721                }
 722            }
 723        } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
 724            return contactJid;
 725        } else {
 726            return this.getContact().getDisplayName();
 727        }
 728    }
 729
 730    public String getAccountUuid() {
 731        return this.accountUuid;
 732    }
 733
 734    public Account getAccount() {
 735        return this.account;
 736    }
 737
 738    public void setAccount(final Account account) {
 739        this.account = account;
 740    }
 741
 742    public Contact getContact() {
 743        return this.account.getRoster().getContact(this.contactJid);
 744    }
 745
 746    @Override
 747    public Jid getJid() {
 748        return this.contactJid;
 749    }
 750
 751    public int getStatus() {
 752        return this.status;
 753    }
 754
 755    public void setStatus(int status) {
 756        this.status = status;
 757    }
 758
 759    public long getCreated() {
 760        return this.created;
 761    }
 762
 763    public ContentValues getContentValues() {
 764        ContentValues values = new ContentValues();
 765        values.put(UUID, uuid);
 766        values.put(NAME, name);
 767        values.put(CONTACT, contactUuid);
 768        values.put(ACCOUNT, accountUuid);
 769        values.put(CONTACTJID, contactJid.toString());
 770        values.put(CREATED, created);
 771        values.put(STATUS, status);
 772        values.put(MODE, mode);
 773        synchronized (this.attributes) {
 774            values.put(ATTRIBUTES, attributes.toString());
 775        }
 776        return values;
 777    }
 778
 779    public int getMode() {
 780        return this.mode;
 781    }
 782
 783    public void setMode(int mode) {
 784        this.mode = mode;
 785    }
 786
 787    /**
 788     * short for is Private and Non-anonymous
 789     */
 790    public boolean isSingleOrPrivateAndNonAnonymous() {
 791        return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
 792    }
 793
 794    public boolean isPrivateAndNonAnonymous() {
 795        return getMucOptions().isPrivateAndNonAnonymous();
 796    }
 797
 798    public synchronized MucOptions getMucOptions() {
 799        if (this.mucOptions == null) {
 800            this.mucOptions = new MucOptions(this);
 801        }
 802        return this.mucOptions;
 803    }
 804
 805    public void resetMucOptions() {
 806        this.mucOptions = null;
 807    }
 808
 809    public void setContactJid(final Jid jid) {
 810        this.contactJid = jid;
 811    }
 812
 813    public Jid getNextCounterpart() {
 814        return this.nextCounterpart;
 815    }
 816
 817    public void setNextCounterpart(Jid jid) {
 818        this.nextCounterpart = jid;
 819    }
 820
 821    public int getNextEncryption() {
 822        if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
 823            return Message.ENCRYPTION_NONE;
 824        }
 825        if (OmemoSetting.isAlways()) {
 826            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
 827        }
 828        final int defaultEncryption;
 829        if (suitableForOmemoByDefault(this)) {
 830            defaultEncryption = OmemoSetting.getEncryption();
 831        } else {
 832            defaultEncryption = Message.ENCRYPTION_NONE;
 833        }
 834        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
 835        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
 836            return defaultEncryption;
 837        } else {
 838            return encryption;
 839        }
 840    }
 841
 842    public boolean setNextEncryption(int encryption) {
 843        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
 844    }
 845
 846    public String getNextMessage() {
 847        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 848        return nextMessage == null ? "" : nextMessage;
 849    }
 850
 851    public @Nullable
 852    Draft getDraft() {
 853        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
 854        if (timestamp > getLatestMessage().getTimeSent()) {
 855            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 856            if (!TextUtils.isEmpty(message) && timestamp != 0) {
 857                return new Draft(message, timestamp);
 858            }
 859        }
 860        return null;
 861    }
 862
 863    public boolean setNextMessage(final String input) {
 864        final String message = input == null || input.trim().isEmpty() ? null : input;
 865        boolean changed = !getNextMessage().equals(message);
 866        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
 867        if (changed) {
 868            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
 869        }
 870        return changed;
 871    }
 872
 873    public Bookmark getBookmark() {
 874        return this.account.getBookmark(this.contactJid);
 875    }
 876
 877    public Message findDuplicateMessage(Message message) {
 878        synchronized (this.messages) {
 879            for (int i = this.messages.size() - 1; i >= 0; --i) {
 880                if (this.messages.get(i).similar(message)) {
 881                    return this.messages.get(i);
 882                }
 883            }
 884        }
 885        return null;
 886    }
 887
 888    public boolean hasDuplicateMessage(Message message) {
 889        return findDuplicateMessage(message) != null;
 890    }
 891
 892    public Message findSentMessageWithBody(String body) {
 893        synchronized (this.messages) {
 894            for (int i = this.messages.size() - 1; i >= 0; --i) {
 895                Message message = this.messages.get(i);
 896                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
 897                    String otherBody;
 898                    if (message.hasFileOnRemoteHost()) {
 899                        otherBody = message.getFileParams().url;
 900                    } else {
 901                        otherBody = message.body;
 902                    }
 903                    if (otherBody != null && otherBody.equals(body)) {
 904                        return message;
 905                    }
 906                }
 907            }
 908            return null;
 909        }
 910    }
 911
 912    public Message findRtpSession(final String sessionId, final int s) {
 913        synchronized (this.messages) {
 914            for (int i = this.messages.size() - 1; i >= 0; --i) {
 915                final Message message = this.messages.get(i);
 916                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
 917                    return message;
 918                }
 919            }
 920        }
 921        return null;
 922    }
 923
 924    public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
 925        if (serverMsgId == null || remoteMsgId == null) {
 926            return false;
 927        }
 928        synchronized (this.messages) {
 929            for (Message message : this.messages) {
 930                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
 931                    return true;
 932                }
 933            }
 934        }
 935        return false;
 936    }
 937
 938    public MamReference getLastMessageTransmitted() {
 939        final MamReference lastClear = getLastClearHistory();
 940        MamReference lastReceived = new MamReference(0);
 941        synchronized (this.messages) {
 942            for (int i = this.messages.size() - 1; i >= 0; --i) {
 943                final Message message = this.messages.get(i);
 944                if (message.isPrivateMessage()) {
 945                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
 946                }
 947                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
 948                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
 949                    break;
 950                }
 951            }
 952        }
 953        return MamReference.max(lastClear, lastReceived);
 954    }
 955
 956    public void setMutedTill(long value) {
 957        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
 958    }
 959
 960    public boolean isMuted() {
 961        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
 962    }
 963
 964    public boolean alwaysNotify() {
 965        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
 966    }
 967
 968    public boolean setAttribute(String key, boolean value) {
 969        return setAttribute(key, String.valueOf(value));
 970    }
 971
 972    private boolean setAttribute(String key, long value) {
 973        return setAttribute(key, Long.toString(value));
 974    }
 975
 976    private boolean setAttribute(String key, int value) {
 977        return setAttribute(key, String.valueOf(value));
 978    }
 979
 980    public boolean setAttribute(String key, String value) {
 981        synchronized (this.attributes) {
 982            try {
 983                if (value == null) {
 984                    if (this.attributes.has(key)) {
 985                        this.attributes.remove(key);
 986                        return true;
 987                    } else {
 988                        return false;
 989                    }
 990                } else {
 991                    final String prev = this.attributes.optString(key, null);
 992                    this.attributes.put(key, value);
 993                    return !value.equals(prev);
 994                }
 995            } catch (JSONException e) {
 996                throw new AssertionError(e);
 997            }
 998        }
 999    }
1000
1001    public boolean setAttribute(String key, List<Jid> jids) {
1002        JSONArray array = new JSONArray();
1003        for (Jid jid : jids) {
1004            array.put(jid.asBareJid().toString());
1005        }
1006        synchronized (this.attributes) {
1007            try {
1008                this.attributes.put(key, array);
1009                return true;
1010            } catch (JSONException e) {
1011                return false;
1012            }
1013        }
1014    }
1015
1016    public String getAttribute(String key) {
1017        synchronized (this.attributes) {
1018            return this.attributes.optString(key, null);
1019        }
1020    }
1021
1022    private List<Jid> getJidListAttribute(String key) {
1023        ArrayList<Jid> list = new ArrayList<>();
1024        synchronized (this.attributes) {
1025            try {
1026                JSONArray array = this.attributes.getJSONArray(key);
1027                for (int i = 0; i < array.length(); ++i) {
1028                    try {
1029                        list.add(Jid.of(array.getString(i)));
1030                    } catch (IllegalArgumentException e) {
1031                        //ignored
1032                    }
1033                }
1034            } catch (JSONException e) {
1035                //ignored
1036            }
1037        }
1038        return list;
1039    }
1040
1041    private int getIntAttribute(String key, int defaultValue) {
1042        String value = this.getAttribute(key);
1043        if (value == null) {
1044            return defaultValue;
1045        } else {
1046            try {
1047                return Integer.parseInt(value);
1048            } catch (NumberFormatException e) {
1049                return defaultValue;
1050            }
1051        }
1052    }
1053
1054    public long getLongAttribute(String key, long defaultValue) {
1055        String value = this.getAttribute(key);
1056        if (value == null) {
1057            return defaultValue;
1058        } else {
1059            try {
1060                return Long.parseLong(value);
1061            } catch (NumberFormatException e) {
1062                return defaultValue;
1063            }
1064        }
1065    }
1066
1067    public boolean getBooleanAttribute(String key, boolean defaultValue) {
1068        String value = this.getAttribute(key);
1069        if (value == null) {
1070            return defaultValue;
1071        } else {
1072            return Boolean.parseBoolean(value);
1073        }
1074    }
1075
1076    public void add(Message message) {
1077        synchronized (this.messages) {
1078            this.messages.add(message);
1079        }
1080    }
1081
1082    public void prepend(int offset, Message message) {
1083        synchronized (this.messages) {
1084            this.messages.add(Math.min(offset, this.messages.size()), message);
1085        }
1086    }
1087
1088    public void addAll(int index, List<Message> messages) {
1089        synchronized (this.messages) {
1090            this.messages.addAll(index, messages);
1091        }
1092        account.getPgpDecryptionService().decrypt(messages);
1093    }
1094
1095    public void expireOldMessages(long timestamp) {
1096        synchronized (this.messages) {
1097            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1098                if (iterator.next().getTimeSent() < timestamp) {
1099                    iterator.remove();
1100                }
1101            }
1102            untieMessages();
1103        }
1104    }
1105
1106    public void sort() {
1107        synchronized (this.messages) {
1108            Collections.sort(this.messages, (left, right) -> {
1109                if (left.getTimeSent() < right.getTimeSent()) {
1110                    return -1;
1111                } else if (left.getTimeSent() > right.getTimeSent()) {
1112                    return 1;
1113                } else {
1114                    return 0;
1115                }
1116            });
1117            untieMessages();
1118        }
1119    }
1120
1121    private void untieMessages() {
1122        for (Message message : this.messages) {
1123            message.untie();
1124        }
1125    }
1126
1127    public int unreadCount() {
1128        synchronized (this.messages) {
1129            int count = 0;
1130            for(final Message message : Lists.reverse(this.messages)) {
1131                if (message.isRead()) {
1132                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1133                        continue;
1134                    }
1135                    return count;
1136                }
1137                ++count;
1138            }
1139            return count;
1140        }
1141    }
1142
1143    public int receivedMessagesCount() {
1144        int count = 0;
1145        synchronized (this.messages) {
1146            for (Message message : messages) {
1147                if (message.getStatus() == Message.STATUS_RECEIVED) {
1148                    ++count;
1149                }
1150            }
1151        }
1152        return count;
1153    }
1154
1155    public int sentMessagesCount() {
1156        int count = 0;
1157        synchronized (this.messages) {
1158            for (Message message : messages) {
1159                if (message.getStatus() != Message.STATUS_RECEIVED) {
1160                    ++count;
1161                }
1162            }
1163        }
1164        return count;
1165    }
1166
1167    public boolean canInferPresence() {
1168        final Contact contact = getContact();
1169        if (contact != null && contact.canInferPresence()) return true;
1170        return sentMessagesCount() > 0;
1171    }
1172
1173    public boolean isWithStranger() {
1174        final Contact contact = getContact();
1175        return mode == MODE_SINGLE
1176                && !contact.isOwnServer()
1177                && !contact.showInContactList()
1178                && !contact.isSelf()
1179                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1180                && sentMessagesCount() == 0;
1181    }
1182
1183    public int getReceivedMessagesCountSinceUuid(String uuid) {
1184        if (uuid == null) {
1185            return 0;
1186        }
1187        int count = 0;
1188        synchronized (this.messages) {
1189            for (int i = messages.size() - 1; i >= 0; i--) {
1190                final Message message = messages.get(i);
1191                if (uuid.equals(message.getUuid())) {
1192                    return count;
1193                }
1194                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1195                    ++count;
1196                }
1197            }
1198        }
1199        return 0;
1200    }
1201
1202    @Override
1203    public int getAvatarBackgroundColor() {
1204        return UIHelper.getColorForName(getName().toString());
1205    }
1206
1207    @Override
1208    public String getAvatarName() {
1209        return getName().toString();
1210    }
1211
1212    public void setCurrentTab(int tab) {
1213        mCurrentTab = tab;
1214    }
1215
1216    public int getCurrentTab() {
1217        if (mCurrentTab >= 0) return mCurrentTab;
1218
1219        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1220            return 0;
1221        }
1222
1223        return 1;
1224    }
1225
1226    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1227        pagerAdapter.startCommand(command, xmppConnectionService);
1228    }
1229
1230    public void setupViewPager(ViewPager pager, TabLayout tabs) {
1231        pagerAdapter.setupViewPager(pager, tabs);
1232    }
1233
1234    public void showViewPager() {
1235        pagerAdapter.show();
1236    }
1237
1238    public void hideViewPager() {
1239        pagerAdapter.hide();
1240    }
1241
1242    public interface OnMessageFound {
1243        void onMessageFound(final Message message);
1244    }
1245
1246    public static class Draft {
1247        private final String message;
1248        private final long timestamp;
1249
1250        private Draft(String message, long timestamp) {
1251            this.message = message;
1252            this.timestamp = timestamp;
1253        }
1254
1255        public long getTimestamp() {
1256            return timestamp;
1257        }
1258
1259        public String getMessage() {
1260            return message;
1261        }
1262    }
1263
1264    public class ConversationPagerAdapter extends PagerAdapter {
1265        protected ViewPager mPager = null;
1266        protected TabLayout mTabs = null;
1267        ArrayList<CommandSession> sessions = null;
1268        protected View page1 = null;
1269        protected View page2 = null;
1270
1271        public void setupViewPager(ViewPager pager, TabLayout tabs) {
1272            mPager = pager;
1273            mTabs = tabs;
1274
1275            if (mPager == null) return;
1276            if (sessions != null) show();
1277
1278            page1 = pager.getChildAt(0) == null ? page1 : pager.getChildAt(0);
1279            page2 = pager.getChildAt(1) == null ? page2 : pager.getChildAt(1);
1280            pager.setAdapter(this);
1281            tabs.setupWithViewPager(mPager);
1282            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1283
1284            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1285                public void onPageScrollStateChanged(int state) { }
1286                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1287
1288                public void onPageSelected(int position) {
1289                    setCurrentTab(position);
1290                }
1291            });
1292        }
1293
1294        public void show() {
1295            if (sessions == null) {
1296                sessions = new ArrayList<>();
1297                notifyDataSetChanged();
1298            }
1299            if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1300        }
1301
1302        public void hide() {
1303            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1304            if (mPager != null) mPager.setCurrentItem(0);
1305            if (mTabs != null) mTabs.setVisibility(View.GONE);
1306            sessions = null;
1307            notifyDataSetChanged();
1308        }
1309
1310        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1311            show();
1312            CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1313
1314            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1315            packet.setTo(command.getAttributeAsJid("jid"));
1316            final Element c = packet.addChild("command", Namespace.COMMANDS);
1317            c.setAttribute("node", command.getAttribute("node"));
1318            c.setAttribute("action", "execute");
1319            View v = mPager;
1320            xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1321                v.post(() -> {
1322                    session.updateWithResponse(iq);
1323                });
1324            });
1325
1326            sessions.add(session);
1327            notifyDataSetChanged();
1328            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1329        }
1330
1331        public void removeSession(CommandSession session) {
1332            sessions.remove(session);
1333            notifyDataSetChanged();
1334        }
1335
1336        @NonNull
1337        @Override
1338        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1339            if (position == 0) {
1340                if (page1.getParent() == null) container.addView(page1);
1341                return page1;
1342            }
1343            if (position == 1) {
1344                if (page2.getParent() == null) container.addView(page2);
1345                return page2;
1346            }
1347
1348            CommandSession session = sessions.get(position-2);
1349            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1350            container.addView(binding.getRoot());
1351            session.setBinding(binding);
1352            return session;
1353        }
1354
1355        @Override
1356        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1357            if (position < 2) return;
1358
1359            container.removeView(((CommandSession) o).getView());
1360        }
1361
1362        @Override
1363        public int getItemPosition(Object o) {
1364            if (mPager != null) {
1365                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1366                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1367            }
1368
1369            int pos = sessions == null ? -1 : sessions.indexOf(o);
1370            if (pos < 0) return PagerAdapter.POSITION_NONE;
1371            return pos + 2;
1372        }
1373
1374        @Override
1375        public int getCount() {
1376            if (sessions == null) return 1;
1377
1378            int count = 2 + sessions.size();
1379            if (mTabs == null) return count;
1380
1381            if (count > 2) {
1382                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1383            } else {
1384                mTabs.setTabMode(TabLayout.MODE_FIXED);
1385            }
1386            return count;
1387        }
1388
1389        @Override
1390        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1391            if (view == o) return true;
1392
1393            if (o instanceof CommandSession) {
1394                return ((CommandSession) o).getView() == view;
1395            }
1396
1397            return false;
1398        }
1399
1400        @Nullable
1401        @Override
1402        public CharSequence getPageTitle(int position) {
1403            switch (position) {
1404                case 0:
1405                    return "Conversation";
1406                case 1:
1407                    return "Commands";
1408                default:
1409                    CommandSession session = sessions.get(position-2);
1410                    if (session == null) return super.getPageTitle(position);
1411                    return session.getTitle();
1412            }
1413        }
1414
1415        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1416            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1417                protected T binding;
1418
1419                public ViewHolder(T binding) {
1420                    super(binding.getRoot());
1421                    this.binding = binding;
1422                }
1423
1424                abstract public void bind(Item el);
1425
1426                protected void setTextOrHide(TextView v, Optional<String> s) {
1427                    if (s == null || !s.isPresent()) {
1428                        v.setVisibility(View.GONE);
1429                    } else {
1430                        v.setVisibility(View.VISIBLE);
1431                        v.setText(s.get());
1432                    }
1433                }
1434
1435                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1436                    int flags = 0;
1437                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1438                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1439
1440                    String type = field.getAttribute("type");
1441                    if (type != null) {
1442                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1443                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1444                        }
1445
1446                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1447
1448                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1449                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1450                        }
1451
1452                        if (type.equals("text-private")) {
1453                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1454                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1455                        }
1456                    }
1457
1458                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1459                    if (validate == null) return;
1460                    String datatype = validate.getAttribute("datatype");
1461                    if (datatype == null) return;
1462
1463                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1464                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1465                    }
1466
1467                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1468                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1469                    }
1470
1471                    if (datatype.equals("xs:date")) {
1472                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1473                    }
1474
1475                    if (datatype.equals("xs:dateTime")) {
1476                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1477                    }
1478
1479                    if (datatype.equals("xs:time")) {
1480                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1481                    }
1482
1483                    if (datatype.equals("xs:anyURI")) {
1484                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1485                    }
1486
1487                    if (datatype.equals("html:tel")) {
1488                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1489                    }
1490
1491                    if (datatype.equals("html:email")) {
1492                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1493                    }
1494                }
1495            }
1496
1497            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1498                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1499
1500                @Override
1501                public void bind(Item iq) {
1502                    binding.errorIcon.setVisibility(View.VISIBLE);
1503
1504                    Element error = iq.el.findChild("error");
1505                    if (error == null) return;
1506                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1507                    if (text == null || text.equals("")) {
1508                        text = error.getChildren().get(0).getName();
1509                    }
1510                    binding.message.setText(text);
1511                }
1512            }
1513
1514            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1515                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1516
1517                @Override
1518                public void bind(Item note) {
1519                    binding.message.setText(note.el.getContent());
1520
1521                    String type = note.el.getAttribute("type");
1522                    if (type != null && type.equals("error")) {
1523                        binding.errorIcon.setVisibility(View.VISIBLE);
1524                    }
1525                }
1526            }
1527
1528            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1529                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1530
1531                @Override
1532                public void bind(Item item) {
1533                    Field field = (Field) item;
1534                    setTextOrHide(binding.label, field.getLabel());
1535                    setTextOrHide(binding.desc, field.getDesc());
1536
1537                    ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1538                    for (Element el : field.el.getChildren()) {
1539                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1540                            values.add(el.getContent());
1541                        }
1542                    }
1543                    binding.values.setAdapter(values);
1544
1545                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1546                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1547                            new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1548                        });
1549                    }
1550
1551                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1552                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1553                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1554                        }
1555                        return true;
1556                    });
1557                }
1558            }
1559
1560            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1561                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1562
1563                @Override
1564                public void bind(Item item) {
1565                    Cell cell = (Cell) item;
1566
1567                    if (cell.el == null) {
1568                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1569                        setTextOrHide(binding.text, cell.reported.getLabel());
1570                    } else {
1571                        SpannableStringBuilder text = new SpannableStringBuilder(cell.el.findChildContent("value", "jabber:x:data"));
1572                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1573                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1574                        }
1575
1576                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1577                        binding.text.setText(text);
1578
1579                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1580                        method.setOnLinkLongClickListener((tv, url) -> {
1581                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1582                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1583                            return true;
1584                        });
1585                        binding.text.setMovementMethod(method);
1586                    }
1587                }
1588            }
1589
1590            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1591                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1592                    super(binding);
1593                    binding.row.setOnClickListener((v) -> {
1594                        binding.checkbox.toggle();
1595                    });
1596                    binding.checkbox.setOnCheckedChangeListener(this);
1597                }
1598                protected Element mValue = null;
1599
1600                @Override
1601                public void bind(Item item) {
1602                    Field field = (Field) item;
1603                    binding.label.setText(field.getLabel().or(""));
1604                    setTextOrHide(binding.desc, field.getDesc());
1605                    mValue = field.getValue();
1606                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1607                }
1608
1609                @Override
1610                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1611                    if (mValue == null) return;
1612
1613                    mValue.setContent(isChecked ? "true" : "false");
1614                }
1615            }
1616
1617            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1618                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1619                    super(binding);
1620                    binding.search.addTextChangedListener(this);
1621                }
1622                protected Element mValue = null;
1623                List<Option> options = new ArrayList<>();
1624                protected ArrayAdapter<Option> adapter;
1625                protected boolean open;
1626
1627                @Override
1628                public void bind(Item item) {
1629                    Field field = (Field) item;
1630                    setTextOrHide(binding.label, field.getLabel());
1631                    setTextOrHide(binding.desc, field.getDesc());
1632
1633                    if (field.error != null) {
1634                        binding.desc.setVisibility(View.VISIBLE);
1635                        binding.desc.setText(field.error);
1636                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1637                    } else {
1638                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1639                    }
1640
1641                    mValue = field.getValue();
1642
1643                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1644                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1645                    setupInputType(field.el, binding.search, null);
1646
1647                    options = field.getOptions();
1648                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1649                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1650                        if (open) binding.search.setText(mValue.getContent());
1651                    });
1652                    search("");
1653                }
1654
1655                @Override
1656                public void afterTextChanged(Editable s) {
1657                    if (open) mValue.setContent(s.toString());
1658                    search(s.toString());
1659                }
1660
1661                @Override
1662                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1663
1664                @Override
1665                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1666
1667                protected void search(String s) {
1668                    List<Option> filteredOptions;
1669                    final String q = s.replaceAll("\\W", "").toLowerCase();
1670                    if (q == null || q.equals("")) {
1671                        filteredOptions = options;
1672                    } else {
1673                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1674                    }
1675                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1676                    binding.list.setAdapter(adapter);
1677
1678                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1679                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1680                }
1681            }
1682
1683            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1684                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1685                    super(binding);
1686                    binding.open.addTextChangedListener(this);
1687                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1688                        @Override
1689                        public View getView(int position, View convertView, ViewGroup parent) {
1690                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1691                            v.setId(position);
1692                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1693                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1694                            return v;
1695                        }
1696                    };
1697                }
1698                protected Element mValue = null;
1699                protected ArrayAdapter<Option> options;
1700
1701                @Override
1702                public void bind(Item item) {
1703                    Field field = (Field) item;
1704                    setTextOrHide(binding.label, field.getLabel());
1705                    setTextOrHide(binding.desc, field.getDesc());
1706
1707                    if (field.error != null) {
1708                        binding.desc.setVisibility(View.VISIBLE);
1709                        binding.desc.setText(field.error);
1710                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1711                    } else {
1712                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1713                    }
1714
1715                    mValue = field.getValue();
1716
1717                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1718                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1719                    binding.open.setText(mValue.getContent());
1720                    setupInputType(field.el, binding.open, null);
1721
1722                    options.clear();
1723                    List<Option> theOptions = field.getOptions();
1724                    options.addAll(theOptions);
1725
1726                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1727                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1728                    float maxColumnWidth = theOptions.stream().map((x) ->
1729                        StaticLayout.getDesiredWidth(x.toString(), paint)
1730                    ).max(Float::compare).orElse(new Float(0.0));
1731                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1732                        binding.radios.setNumColumns(theOptions.size());
1733                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1734                        binding.radios.setNumColumns(theOptions.size() / 2);
1735                    } else {
1736                        binding.radios.setNumColumns(1);
1737                    }
1738                    binding.radios.setAdapter(options);
1739                }
1740
1741                @Override
1742                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1743                    if (mValue == null) return;
1744
1745                    if (isChecked) {
1746                        mValue.setContent(options.getItem(radio.getId()).getValue());
1747                        binding.open.setText(mValue.getContent());
1748                    }
1749                    options.notifyDataSetChanged();
1750                }
1751
1752                @Override
1753                public void afterTextChanged(Editable s) {
1754                    if (mValue == null) return;
1755
1756                    mValue.setContent(s.toString());
1757                    options.notifyDataSetChanged();
1758                }
1759
1760                @Override
1761                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1762
1763                @Override
1764                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1765            }
1766
1767            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1768                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1769                    super(binding);
1770                    binding.spinner.setOnItemSelectedListener(this);
1771                }
1772                protected Element mValue = null;
1773
1774                @Override
1775                public void bind(Item item) {
1776                    Field field = (Field) item;
1777                    setTextOrHide(binding.label, field.getLabel());
1778                    binding.spinner.setPrompt(field.getLabel().or(""));
1779                    setTextOrHide(binding.desc, field.getDesc());
1780
1781                    mValue = field.getValue();
1782
1783                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1784                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1785                    options.addAll(field.getOptions());
1786
1787                    binding.spinner.setAdapter(options);
1788                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1789                }
1790
1791                @Override
1792                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1793                    Option o = (Option) parent.getItemAtPosition(pos);
1794                    if (mValue == null) return;
1795
1796                    mValue.setContent(o == null ? "" : o.getValue());
1797                }
1798
1799                @Override
1800                public void onNothingSelected(AdapterView<?> parent) {
1801                    mValue.setContent("");
1802                }
1803            }
1804
1805            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1806                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1807                    super(binding);
1808                    binding.textinput.addTextChangedListener(this);
1809                }
1810                protected Element mValue = null;
1811
1812                @Override
1813                public void bind(Item item) {
1814                    Field field = (Field) item;
1815                    binding.textinputLayout.setHint(field.getLabel().or(""));
1816
1817                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1818                    for (String desc : field.getDesc().asSet()) {
1819                        binding.textinputLayout.setHelperText(desc);
1820                    }
1821
1822                    binding.textinputLayout.setErrorEnabled(field.error != null);
1823                    if (field.error != null) binding.textinputLayout.setError(field.error);
1824
1825                    mValue = field.getValue();
1826                    binding.textinput.setText(mValue.getContent());
1827                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
1828                }
1829
1830                @Override
1831                public void afterTextChanged(Editable s) {
1832                    if (mValue == null) return;
1833
1834                    mValue.setContent(s.toString());
1835                }
1836
1837                @Override
1838                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1839
1840                @Override
1841                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1842            }
1843
1844            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1845                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1846                protected String boundUrl = "";
1847
1848                @Override
1849                public void bind(Item oob) {
1850                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1851                    binding.webview.getSettings().setJavaScriptEnabled(true);
1852                    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");
1853                    binding.webview.getSettings().setDatabaseEnabled(true);
1854                    binding.webview.getSettings().setDomStorageEnabled(true);
1855                    binding.webview.setWebChromeClient(new WebChromeClient() {
1856                        @Override
1857                        public void onProgressChanged(WebView view, int newProgress) {
1858                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1859                            binding.progressbar.setProgress(newProgress);
1860                        }
1861                    });
1862                    binding.webview.setWebViewClient(new WebViewClient() {
1863                        @Override
1864                        public void onPageFinished(WebView view, String url) {
1865                            super.onPageFinished(view, url);
1866                            mTitle = view.getTitle();
1867                            ConversationPagerAdapter.this.notifyDataSetChanged();
1868                        }
1869                    });
1870                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
1871                    if (!boundUrl.equals(url)) {
1872                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1873                        binding.webview.loadUrl(url);
1874                        boundUrl = url;
1875                    }
1876                }
1877
1878                class JsObject {
1879                    @JavascriptInterface
1880                    public void execute() { execute("execute"); }
1881
1882                    @JavascriptInterface
1883                    public void execute(String action) {
1884                        getView().post(() -> {
1885                            actionToWebview = null;
1886                            if(CommandSession.this.execute(action)) {
1887                                removeSession(CommandSession.this);
1888                            }
1889                        });
1890                    }
1891
1892                    @JavascriptInterface
1893                    public void preventDefault() {
1894                        actionToWebview = binding.webview;
1895                    }
1896                }
1897            }
1898
1899            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1900                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1901
1902                @Override
1903                public void bind(Item item) { }
1904            }
1905
1906            class Item {
1907                protected Element el;
1908                protected int viewType;
1909                protected String error = null;
1910
1911                Item(Element el, int viewType) {
1912                    this.el = el;
1913                    this.viewType = viewType;
1914                }
1915
1916                public boolean validate() {
1917                    error = null;
1918                    return true;
1919                }
1920            }
1921
1922            class Field extends Item {
1923                Field(Element el, int viewType) { super(el, viewType); }
1924
1925                @Override
1926                public boolean validate() {
1927                    if (!super.validate()) return false;
1928                    if (el.findChild("required", "jabber:x:data") == null) return true;
1929                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1930
1931                    error = "this value is required";
1932                    return false;
1933                }
1934
1935                public String getVar() {
1936                    return el.getAttribute("var");
1937                }
1938
1939                public Optional<String> getType() {
1940                    return Optional.fromNullable(el.getAttribute("type"));
1941                }
1942
1943                public Optional<String> getLabel() {
1944                    String label = el.getAttribute("label");
1945                    if (label == null) label = getVar();
1946                    return Optional.fromNullable(label);
1947                }
1948
1949                public Optional<String> getDesc() {
1950                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1951                }
1952
1953                public Element getValue() {
1954                    Element value = el.findChild("value", "jabber:x:data");
1955                    if (value == null) {
1956                        value = el.addChild("value", "jabber:x:data");
1957                    }
1958                    return value;
1959                }
1960
1961                public List<Option> getOptions() {
1962                    return Option.forField(el);
1963                }
1964            }
1965
1966            class Cell extends Item {
1967                protected Field reported;
1968
1969                Cell(Field reported, Element item) {
1970                    super(item, TYPE_RESULT_CELL);
1971                    this.reported = reported;
1972                }
1973            }
1974
1975            protected Field mkField(Element el) {
1976                int viewType = -1;
1977
1978                String formType = responseElement.getAttribute("type");
1979                if (formType != null) {
1980                    String fieldType = el.getAttribute("type");
1981                    if (fieldType == null) fieldType = "text-single";
1982
1983                    if (formType.equals("result") || fieldType.equals("fixed")) {
1984                        viewType = TYPE_RESULT_FIELD;
1985                    } else if (formType.equals("form")) {
1986                        if (fieldType.equals("boolean")) {
1987                            viewType = TYPE_CHECKBOX_FIELD;
1988                        } else if (fieldType.equals("list-single")) {
1989                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1990                            if (Option.forField(el).size() > 9) {
1991                                viewType = TYPE_SEARCH_LIST_FIELD;
1992                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1993                                viewType = TYPE_RADIO_EDIT_FIELD;
1994                            } else {
1995                                viewType = TYPE_SPINNER_FIELD;
1996                            }
1997                        } else {
1998                            viewType = TYPE_TEXT_FIELD;
1999                        }
2000                    }
2001
2002                    return new Field(el, viewType);
2003                }
2004
2005                return null;
2006            }
2007
2008            protected Item mkItem(Element el, int pos) {
2009                int viewType = -1;
2010
2011                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2012                    if (el.getName().equals("note")) {
2013                        viewType = TYPE_NOTE;
2014                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2015                        viewType = TYPE_WEB;
2016                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2017                        viewType = TYPE_NOTE;
2018                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2019                        Field field = mkField(el);
2020                        if (field != null) {
2021                            items.put(pos, field);
2022                            return field;
2023                        }
2024                    }
2025                } else if (response != null) {
2026                    viewType = TYPE_ERROR;
2027                }
2028
2029                Item item = new Item(el, viewType);
2030                items.put(pos, item);
2031                return item;
2032            }
2033
2034            final int TYPE_ERROR = 1;
2035            final int TYPE_NOTE = 2;
2036            final int TYPE_WEB = 3;
2037            final int TYPE_RESULT_FIELD = 4;
2038            final int TYPE_TEXT_FIELD = 5;
2039            final int TYPE_CHECKBOX_FIELD = 6;
2040            final int TYPE_SPINNER_FIELD = 7;
2041            final int TYPE_RADIO_EDIT_FIELD = 8;
2042            final int TYPE_RESULT_CELL = 9;
2043            final int TYPE_PROGRESSBAR = 10;
2044            final int TYPE_SEARCH_LIST_FIELD = 11;
2045
2046            protected boolean loading = false;
2047            protected Timer loadingTimer = new Timer();
2048            protected String mTitle;
2049            protected CommandPageBinding mBinding = null;
2050            protected IqPacket response = null;
2051            protected Element responseElement = null;
2052            protected List<Field> reported = null;
2053            protected SparseArray<Item> items = new SparseArray<>();
2054            protected XmppConnectionService xmppConnectionService;
2055            protected ArrayAdapter<String> actionsAdapter;
2056            protected GridLayoutManager layoutManager;
2057            protected WebView actionToWebview = null;
2058
2059            CommandSession(String title, XmppConnectionService xmppConnectionService) {
2060                loading();
2061                mTitle = title;
2062                this.xmppConnectionService = xmppConnectionService;
2063                if (mPager != null) setupLayoutManager();
2064                actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2065                    @Override
2066                    public View getView(int position, View convertView, ViewGroup parent) {
2067                        View v = super.getView(position, convertView, parent);
2068                        TextView tv = (TextView) v.findViewById(android.R.id.text1);
2069                        tv.setGravity(Gravity.CENTER);
2070                        int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2071                        if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2072                        tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2073                        tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2074                        return v;
2075                    }
2076                };
2077                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2078                    @Override
2079                    public void onChanged() {
2080                        if (mBinding == null) return;
2081
2082                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2083                    }
2084
2085                    @Override
2086                    public void onInvalidated() {}
2087                });
2088            }
2089
2090            public String getTitle() {
2091                return mTitle;
2092            }
2093
2094            public void updateWithResponse(IqPacket iq) {
2095                this.loadingTimer.cancel();
2096                this.loadingTimer = new Timer();
2097                this.loading = false;
2098                this.responseElement = null;
2099                this.reported = null;
2100                this.response = iq;
2101                this.items.clear();
2102                this.actionsAdapter.clear();
2103                layoutManager.setSpanCount(1);
2104
2105                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2106                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2107                    for (Element el : command.getChildren()) {
2108                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2109                            for (Element action : el.getChildren()) {
2110                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2111                                if (action.getName().equals("execute")) continue;
2112
2113                                actionsAdapter.add(action.getName());
2114                            }
2115                        }
2116                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2117                            String title = el.findChildContent("title", "jabber:x:data");
2118                            if (title != null) {
2119                                mTitle = title;
2120                                ConversationPagerAdapter.this.notifyDataSetChanged();
2121                            }
2122
2123                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2124                                this.responseElement = el;
2125                                setupReported(el.findChild("reported", "jabber:x:data"));
2126                                layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2127                            }
2128                            break;
2129                        }
2130                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2131                            String url = el.findChildContent("url", "jabber:x:oob");
2132                            if (url != null) {
2133                                String scheme = Uri.parse(url).getScheme();
2134                                if (scheme.equals("http") || scheme.equals("https")) {
2135                                    this.responseElement = el;
2136                                    break;
2137                                }
2138                            }
2139                        }
2140                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2141                            this.responseElement = el;
2142                            break;
2143                        }
2144                    }
2145
2146                    if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2147                        removeSession(this);
2148                        return;
2149                    }
2150
2151                    if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2152                        // No actions have been given, but we are not done?
2153                        // This is probably a spec violation, but we should do *something*
2154                        actionsAdapter.add("execute");
2155                    }
2156
2157                    if (!actionsAdapter.isEmpty()) {
2158                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2159                            actionsAdapter.add("close");
2160                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2161                            actionsAdapter.insert("cancel", 0);
2162                        }
2163                    }
2164                }
2165
2166                if (actionsAdapter.isEmpty()) {
2167                    actionsAdapter.add("close");
2168                }
2169
2170                notifyDataSetChanged();
2171            }
2172
2173            protected void setupReported(Element el) {
2174                if (el == null) {
2175                    reported = null;
2176                    return;
2177                }
2178
2179                reported = new ArrayList<>();
2180                for (Element fieldEl : el.getChildren()) {
2181                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2182                    reported.add(mkField(fieldEl));
2183                }
2184            }
2185
2186            @Override
2187            public int getItemCount() {
2188                if (loading) return 1;
2189                if (response == null) return 0;
2190                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2191                    int i = 0;
2192                    for (Element el : responseElement.getChildren()) {
2193                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2194                        if (el.getName().equals("title")) continue;
2195                        if (el.getName().equals("field")) {
2196                            String type = el.getAttribute("type");
2197                            if (type != null && type.equals("hidden")) continue;
2198                        }
2199
2200                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2201                            if (reported != null) i += reported.size();
2202                            continue;
2203                        }
2204
2205                        i++;
2206                    }
2207                    return i;
2208                }
2209                return 1;
2210            }
2211
2212            public Item getItem(int position) {
2213                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2214                if (items.get(position) != null) return items.get(position);
2215                if (response == null) return null;
2216
2217                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2218                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2219                        int i = 0;
2220                        for (Element el : responseElement.getChildren()) {
2221                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2222                            if (el.getName().equals("title")) continue;
2223                            if (el.getName().equals("field")) {
2224                                String type = el.getAttribute("type");
2225                                if (type != null && type.equals("hidden")) continue;
2226                            }
2227
2228                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2229                                Cell cell = null;
2230
2231                                if (reported != null) {
2232                                    if (reported.size() > position - i) {
2233                                        Field reportedField = reported.get(position - i);
2234                                        Element itemField = null;
2235                                        if (el.getName().equals("item")) {
2236                                            for (Element subel : el.getChildren()) {
2237                                                if (subel.getAttribute("var").equals(reportedField.getVar())) {
2238                                                   itemField = subel;
2239                                                   break;
2240                                                }
2241                                            }
2242                                        }
2243                                        cell = new Cell(reportedField, itemField);
2244                                    } else {
2245                                        i += reported.size();
2246                                        continue;
2247                                    }
2248                                }
2249
2250                                if (cell != null) {
2251                                    items.put(position, cell);
2252                                    return cell;
2253                                }
2254                            }
2255
2256                            if (i < position) {
2257                                i++;
2258                                continue;
2259                            }
2260
2261                            return mkItem(el, position);
2262                        }
2263                    }
2264                }
2265
2266                return mkItem(responseElement == null ? response : responseElement, position);
2267            }
2268
2269            @Override
2270            public int getItemViewType(int position) {
2271                return getItem(position).viewType;
2272            }
2273
2274            @Override
2275            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2276                switch(viewType) {
2277                    case TYPE_ERROR: {
2278                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2279                        return new ErrorViewHolder(binding);
2280                    }
2281                    case TYPE_NOTE: {
2282                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2283                        return new NoteViewHolder(binding);
2284                    }
2285                    case TYPE_WEB: {
2286                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2287                        return new WebViewHolder(binding);
2288                    }
2289                    case TYPE_RESULT_FIELD: {
2290                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2291                        return new ResultFieldViewHolder(binding);
2292                    }
2293                    case TYPE_RESULT_CELL: {
2294                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2295                        return new ResultCellViewHolder(binding);
2296                    }
2297                    case TYPE_CHECKBOX_FIELD: {
2298                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2299                        return new CheckboxFieldViewHolder(binding);
2300                    }
2301                    case TYPE_SEARCH_LIST_FIELD: {
2302                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2303                        return new SearchListFieldViewHolder(binding);
2304                    }
2305                    case TYPE_RADIO_EDIT_FIELD: {
2306                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2307                        return new RadioEditFieldViewHolder(binding);
2308                    }
2309                    case TYPE_SPINNER_FIELD: {
2310                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2311                        return new SpinnerFieldViewHolder(binding);
2312                    }
2313                    case TYPE_TEXT_FIELD: {
2314                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2315                        return new TextFieldViewHolder(binding);
2316                    }
2317                    case TYPE_PROGRESSBAR: {
2318                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2319                        return new ProgressBarViewHolder(binding);
2320                    }
2321                    default:
2322                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2323                }
2324            }
2325
2326            @Override
2327            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2328                viewHolder.bind(getItem(position));
2329            }
2330
2331            public View getView() {
2332                return mBinding.getRoot();
2333            }
2334
2335            public boolean validate() {
2336                int count = getItemCount();
2337                boolean isValid = true;
2338                for (int i = 0; i < count; i++) {
2339                    boolean oneIsValid = getItem(i).validate();
2340                    isValid = isValid && oneIsValid;
2341                }
2342                notifyDataSetChanged();
2343                return isValid;
2344            }
2345
2346            public boolean execute() {
2347                return execute("execute");
2348            }
2349
2350            public boolean execute(int actionPosition) {
2351                return execute(actionsAdapter.getItem(actionPosition));
2352            }
2353
2354            public boolean execute(String action) {
2355                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2356
2357                if (response == null) return true;
2358                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2359                if (command == null) return true;
2360                String status = command.getAttribute("status");
2361                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2362
2363                if (actionToWebview != null) {
2364                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2365                    return false;
2366                }
2367
2368                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2369                packet.setTo(response.getFrom());
2370                final Element c = packet.addChild("command", Namespace.COMMANDS);
2371                c.setAttribute("node", command.getAttribute("node"));
2372                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2373                c.setAttribute("action", action);
2374
2375                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2376                if (!action.equals("cancel") &&
2377                    !action.equals("prev") &&
2378                    responseElement != null &&
2379                    responseElement.getName().equals("x") &&
2380                    responseElement.getNamespace().equals("jabber:x:data") &&
2381                    formType != null && formType.equals("form")) {
2382
2383                    responseElement.setAttribute("type", "submit");
2384                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2385                    if (rsm != null) {
2386                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2387                        max.setContent("1000");
2388                        rsm.addChild(max);
2389                    }
2390                    c.addChild(responseElement);
2391                }
2392
2393                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2394                    getView().post(() -> {
2395                        updateWithResponse(iq);
2396                    });
2397                });
2398
2399                loading();
2400                return false;
2401            }
2402
2403            protected void loading() {
2404                loadingTimer.schedule(new TimerTask() {
2405                    @Override
2406                    public void run() {
2407                        getView().post(() -> {
2408                            loading = true;
2409                            notifyDataSetChanged();
2410                        });
2411                    }
2412                }, 500);
2413            }
2414
2415            protected GridLayoutManager setupLayoutManager() {
2416                layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2417                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2418                    @Override
2419                    public int getSpanSize(int position) {
2420                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2421                        return 1;
2422                    }
2423                });
2424                return layoutManager;
2425            }
2426
2427            public void setBinding(CommandPageBinding b) {
2428                mBinding = b;
2429                // https://stackoverflow.com/a/32350474/8611
2430                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2431                    @Override
2432                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2433                        if(rv.getChildCount() > 0) {
2434                            int[] location = new int[2];
2435                            rv.getLocationOnScreen(location);
2436                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2437                            if (childView instanceof ViewGroup) {
2438                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2439                            }
2440                            if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2441                                int action = e.getAction();
2442                                switch (action) {
2443                                    case MotionEvent.ACTION_DOWN:
2444                                        rv.requestDisallowInterceptTouchEvent(true);
2445                                }
2446                            }
2447                        }
2448
2449                        return false;
2450                    }
2451
2452                    @Override
2453                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2454
2455                    @Override
2456                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2457                });
2458                mBinding.form.setLayoutManager(setupLayoutManager());
2459                mBinding.form.setAdapter(this);
2460                mBinding.actions.setAdapter(actionsAdapter);
2461                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2462                    if (execute(pos)) {
2463                        removeSession(CommandSession.this);
2464                    }
2465                });
2466
2467                actionsAdapter.notifyDataSetChanged();
2468            }
2469
2470            // https://stackoverflow.com/a/36037991/8611
2471            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2472                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2473                    View child = viewGroup.getChildAt(i);
2474                    if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2475                        View foundView = findViewAt((ViewGroup) child, x, y);
2476                        if (foundView != null && foundView.isShown()) {
2477                            return foundView;
2478                        }
2479                    } else {
2480                        int[] location = new int[2];
2481                        child.getLocationOnScreen(location);
2482                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2483                        if (rect.contains((int)x, (int)y)) {
2484                            return child;
2485                        }
2486                    }
2487                }
2488
2489                return null;
2490            }
2491        }
2492    }
2493}