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"), command.getAttribute("node"), 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                        String value = cell.el.findChildContent("value", "jabber:x:data");
1572                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1573                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1574                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1575                        }
1576
1577                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1578                        binding.text.setText(text);
1579
1580                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1581                        method.setOnLinkLongClickListener((tv, url) -> {
1582                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1583                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1584                            return true;
1585                        });
1586                        binding.text.setMovementMethod(method);
1587                    }
1588                }
1589            }
1590
1591            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1592                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1593                    super(binding);
1594                    binding.row.setOnClickListener((v) -> {
1595                        binding.checkbox.toggle();
1596                    });
1597                    binding.checkbox.setOnCheckedChangeListener(this);
1598                }
1599                protected Element mValue = null;
1600
1601                @Override
1602                public void bind(Item item) {
1603                    Field field = (Field) item;
1604                    binding.label.setText(field.getLabel().or(""));
1605                    setTextOrHide(binding.desc, field.getDesc());
1606                    mValue = field.getValue();
1607                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1608                }
1609
1610                @Override
1611                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1612                    if (mValue == null) return;
1613
1614                    mValue.setContent(isChecked ? "true" : "false");
1615                }
1616            }
1617
1618            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1619                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1620                    super(binding);
1621                    binding.search.addTextChangedListener(this);
1622                }
1623                protected Element mValue = null;
1624                List<Option> options = new ArrayList<>();
1625                protected ArrayAdapter<Option> adapter;
1626                protected boolean open;
1627
1628                @Override
1629                public void bind(Item item) {
1630                    Field field = (Field) item;
1631                    setTextOrHide(binding.label, field.getLabel());
1632                    setTextOrHide(binding.desc, field.getDesc());
1633
1634                    if (field.error != null) {
1635                        binding.desc.setVisibility(View.VISIBLE);
1636                        binding.desc.setText(field.error);
1637                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1638                    } else {
1639                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1640                    }
1641
1642                    mValue = field.getValue();
1643
1644                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1645                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1646                    setupInputType(field.el, binding.search, null);
1647
1648                    options = field.getOptions();
1649                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1650                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1651                        if (open) binding.search.setText(mValue.getContent());
1652                    });
1653                    search("");
1654                }
1655
1656                @Override
1657                public void afterTextChanged(Editable s) {
1658                    if (open) mValue.setContent(s.toString());
1659                    search(s.toString());
1660                }
1661
1662                @Override
1663                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1664
1665                @Override
1666                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1667
1668                protected void search(String s) {
1669                    List<Option> filteredOptions;
1670                    final String q = s.replaceAll("\\W", "").toLowerCase();
1671                    if (q == null || q.equals("")) {
1672                        filteredOptions = options;
1673                    } else {
1674                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1675                    }
1676                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1677                    binding.list.setAdapter(adapter);
1678
1679                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1680                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1681                }
1682            }
1683
1684            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1685                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1686                    super(binding);
1687                    binding.open.addTextChangedListener(this);
1688                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1689                        @Override
1690                        public View getView(int position, View convertView, ViewGroup parent) {
1691                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1692                            v.setId(position);
1693                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1694                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1695                            return v;
1696                        }
1697                    };
1698                }
1699                protected Element mValue = null;
1700                protected ArrayAdapter<Option> options;
1701
1702                @Override
1703                public void bind(Item item) {
1704                    Field field = (Field) item;
1705                    setTextOrHide(binding.label, field.getLabel());
1706                    setTextOrHide(binding.desc, field.getDesc());
1707
1708                    if (field.error != null) {
1709                        binding.desc.setVisibility(View.VISIBLE);
1710                        binding.desc.setText(field.error);
1711                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1712                    } else {
1713                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1714                    }
1715
1716                    mValue = field.getValue();
1717
1718                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1719                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1720                    binding.open.setText(mValue.getContent());
1721                    setupInputType(field.el, binding.open, null);
1722
1723                    options.clear();
1724                    List<Option> theOptions = field.getOptions();
1725                    options.addAll(theOptions);
1726
1727                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1728                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1729                    float maxColumnWidth = theOptions.stream().map((x) ->
1730                        StaticLayout.getDesiredWidth(x.toString(), paint)
1731                    ).max(Float::compare).orElse(new Float(0.0));
1732                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1733                        binding.radios.setNumColumns(theOptions.size());
1734                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1735                        binding.radios.setNumColumns(theOptions.size() / 2);
1736                    } else {
1737                        binding.radios.setNumColumns(1);
1738                    }
1739                    binding.radios.setAdapter(options);
1740                }
1741
1742                @Override
1743                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1744                    if (mValue == null) return;
1745
1746                    if (isChecked) {
1747                        mValue.setContent(options.getItem(radio.getId()).getValue());
1748                        binding.open.setText(mValue.getContent());
1749                    }
1750                    options.notifyDataSetChanged();
1751                }
1752
1753                @Override
1754                public void afterTextChanged(Editable s) {
1755                    if (mValue == null) return;
1756
1757                    mValue.setContent(s.toString());
1758                    options.notifyDataSetChanged();
1759                }
1760
1761                @Override
1762                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1763
1764                @Override
1765                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1766            }
1767
1768            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1769                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1770                    super(binding);
1771                    binding.spinner.setOnItemSelectedListener(this);
1772                }
1773                protected Element mValue = null;
1774
1775                @Override
1776                public void bind(Item item) {
1777                    Field field = (Field) item;
1778                    setTextOrHide(binding.label, field.getLabel());
1779                    binding.spinner.setPrompt(field.getLabel().or(""));
1780                    setTextOrHide(binding.desc, field.getDesc());
1781
1782                    mValue = field.getValue();
1783
1784                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1785                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1786                    options.addAll(field.getOptions());
1787
1788                    binding.spinner.setAdapter(options);
1789                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1790                }
1791
1792                @Override
1793                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1794                    Option o = (Option) parent.getItemAtPosition(pos);
1795                    if (mValue == null) return;
1796
1797                    mValue.setContent(o == null ? "" : o.getValue());
1798                }
1799
1800                @Override
1801                public void onNothingSelected(AdapterView<?> parent) {
1802                    mValue.setContent("");
1803                }
1804            }
1805
1806            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1807                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1808                    super(binding);
1809                    binding.textinput.addTextChangedListener(this);
1810                }
1811                protected Element mValue = null;
1812
1813                @Override
1814                public void bind(Item item) {
1815                    Field field = (Field) item;
1816                    binding.textinputLayout.setHint(field.getLabel().or(""));
1817
1818                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1819                    for (String desc : field.getDesc().asSet()) {
1820                        binding.textinputLayout.setHelperText(desc);
1821                    }
1822
1823                    binding.textinputLayout.setErrorEnabled(field.error != null);
1824                    if (field.error != null) binding.textinputLayout.setError(field.error);
1825
1826                    mValue = field.getValue();
1827                    binding.textinput.setText(mValue.getContent());
1828                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
1829                }
1830
1831                @Override
1832                public void afterTextChanged(Editable s) {
1833                    if (mValue == null) return;
1834
1835                    mValue.setContent(s.toString());
1836                }
1837
1838                @Override
1839                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1840
1841                @Override
1842                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1843            }
1844
1845            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1846                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1847                protected String boundUrl = "";
1848
1849                @Override
1850                public void bind(Item oob) {
1851                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1852                    binding.webview.getSettings().setJavaScriptEnabled(true);
1853                    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");
1854                    binding.webview.getSettings().setDatabaseEnabled(true);
1855                    binding.webview.getSettings().setDomStorageEnabled(true);
1856                    binding.webview.setWebChromeClient(new WebChromeClient() {
1857                        @Override
1858                        public void onProgressChanged(WebView view, int newProgress) {
1859                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1860                            binding.progressbar.setProgress(newProgress);
1861                        }
1862                    });
1863                    binding.webview.setWebViewClient(new WebViewClient() {
1864                        @Override
1865                        public void onPageFinished(WebView view, String url) {
1866                            super.onPageFinished(view, url);
1867                            mTitle = view.getTitle();
1868                            ConversationPagerAdapter.this.notifyDataSetChanged();
1869                        }
1870                    });
1871                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
1872                    if (!boundUrl.equals(url)) {
1873                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1874                        binding.webview.loadUrl(url);
1875                        boundUrl = url;
1876                    }
1877                }
1878
1879                class JsObject {
1880                    @JavascriptInterface
1881                    public void execute() { execute("execute"); }
1882
1883                    @JavascriptInterface
1884                    public void execute(String action) {
1885                        getView().post(() -> {
1886                            actionToWebview = null;
1887                            if(CommandSession.this.execute(action)) {
1888                                removeSession(CommandSession.this);
1889                            }
1890                        });
1891                    }
1892
1893                    @JavascriptInterface
1894                    public void preventDefault() {
1895                        actionToWebview = binding.webview;
1896                    }
1897                }
1898            }
1899
1900            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1901                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1902
1903                @Override
1904                public void bind(Item item) { }
1905            }
1906
1907            class Item {
1908                protected Element el;
1909                protected int viewType;
1910                protected String error = null;
1911
1912                Item(Element el, int viewType) {
1913                    this.el = el;
1914                    this.viewType = viewType;
1915                }
1916
1917                public boolean validate() {
1918                    error = null;
1919                    return true;
1920                }
1921            }
1922
1923            class Field extends Item {
1924                Field(Element el, int viewType) { super(el, viewType); }
1925
1926                @Override
1927                public boolean validate() {
1928                    if (!super.validate()) return false;
1929                    if (el.findChild("required", "jabber:x:data") == null) return true;
1930                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1931
1932                    error = "this value is required";
1933                    return false;
1934                }
1935
1936                public String getVar() {
1937                    return el.getAttribute("var");
1938                }
1939
1940                public Optional<String> getType() {
1941                    return Optional.fromNullable(el.getAttribute("type"));
1942                }
1943
1944                public Optional<String> getLabel() {
1945                    String label = el.getAttribute("label");
1946                    if (label == null) label = getVar();
1947                    return Optional.fromNullable(label);
1948                }
1949
1950                public Optional<String> getDesc() {
1951                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1952                }
1953
1954                public Element getValue() {
1955                    Element value = el.findChild("value", "jabber:x:data");
1956                    if (value == null) {
1957                        value = el.addChild("value", "jabber:x:data");
1958                    }
1959                    return value;
1960                }
1961
1962                public List<Option> getOptions() {
1963                    return Option.forField(el);
1964                }
1965            }
1966
1967            class Cell extends Item {
1968                protected Field reported;
1969
1970                Cell(Field reported, Element item) {
1971                    super(item, TYPE_RESULT_CELL);
1972                    this.reported = reported;
1973                }
1974            }
1975
1976            protected Field mkField(Element el) {
1977                int viewType = -1;
1978
1979                String formType = responseElement.getAttribute("type");
1980                if (formType != null) {
1981                    String fieldType = el.getAttribute("type");
1982                    if (fieldType == null) fieldType = "text-single";
1983
1984                    if (formType.equals("result") || fieldType.equals("fixed")) {
1985                        viewType = TYPE_RESULT_FIELD;
1986                    } else if (formType.equals("form")) {
1987                        if (fieldType.equals("boolean")) {
1988                            viewType = TYPE_CHECKBOX_FIELD;
1989                        } else if (fieldType.equals("list-single")) {
1990                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1991                            if (Option.forField(el).size() > 9) {
1992                                viewType = TYPE_SEARCH_LIST_FIELD;
1993                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1994                                viewType = TYPE_RADIO_EDIT_FIELD;
1995                            } else {
1996                                viewType = TYPE_SPINNER_FIELD;
1997                            }
1998                        } else {
1999                            viewType = TYPE_TEXT_FIELD;
2000                        }
2001                    }
2002
2003                    return new Field(el, viewType);
2004                }
2005
2006                return null;
2007            }
2008
2009            protected Item mkItem(Element el, int pos) {
2010                int viewType = -1;
2011
2012                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2013                    if (el.getName().equals("note")) {
2014                        viewType = TYPE_NOTE;
2015                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2016                        viewType = TYPE_WEB;
2017                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2018                        viewType = TYPE_NOTE;
2019                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2020                        Field field = mkField(el);
2021                        if (field != null) {
2022                            items.put(pos, field);
2023                            return field;
2024                        }
2025                    }
2026                } else if (response != null) {
2027                    viewType = TYPE_ERROR;
2028                }
2029
2030                Item item = new Item(el, viewType);
2031                items.put(pos, item);
2032                return item;
2033            }
2034
2035            final int TYPE_ERROR = 1;
2036            final int TYPE_NOTE = 2;
2037            final int TYPE_WEB = 3;
2038            final int TYPE_RESULT_FIELD = 4;
2039            final int TYPE_TEXT_FIELD = 5;
2040            final int TYPE_CHECKBOX_FIELD = 6;
2041            final int TYPE_SPINNER_FIELD = 7;
2042            final int TYPE_RADIO_EDIT_FIELD = 8;
2043            final int TYPE_RESULT_CELL = 9;
2044            final int TYPE_PROGRESSBAR = 10;
2045            final int TYPE_SEARCH_LIST_FIELD = 11;
2046
2047            protected boolean loading = false;
2048            protected Timer loadingTimer = new Timer();
2049            protected String mTitle;
2050            protected String mNode;
2051            protected CommandPageBinding mBinding = null;
2052            protected IqPacket response = null;
2053            protected Element responseElement = null;
2054            protected List<Field> reported = null;
2055            protected SparseArray<Item> items = new SparseArray<>();
2056            protected XmppConnectionService xmppConnectionService;
2057            protected ArrayAdapter<String> actionsAdapter;
2058            protected GridLayoutManager layoutManager;
2059            protected WebView actionToWebview = null;
2060
2061            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2062                loading();
2063                mTitle = title;
2064                mNode = node;
2065                this.xmppConnectionService = xmppConnectionService;
2066                if (mPager != null) setupLayoutManager();
2067                actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2068                    @Override
2069                    public View getView(int position, View convertView, ViewGroup parent) {
2070                        View v = super.getView(position, convertView, parent);
2071                        TextView tv = (TextView) v.findViewById(android.R.id.text1);
2072                        tv.setGravity(Gravity.CENTER);
2073                        int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2074                        if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2075                        tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2076                        tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2077                        return v;
2078                    }
2079                };
2080                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2081                    @Override
2082                    public void onChanged() {
2083                        if (mBinding == null) return;
2084
2085                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2086                    }
2087
2088                    @Override
2089                    public void onInvalidated() {}
2090                });
2091            }
2092
2093            public String getTitle() {
2094                return mTitle;
2095            }
2096
2097            public void updateWithResponse(IqPacket iq) {
2098                this.loadingTimer.cancel();
2099                this.loadingTimer = new Timer();
2100                this.loading = false;
2101                this.responseElement = null;
2102                this.reported = null;
2103                this.response = iq;
2104                this.items.clear();
2105                this.actionsAdapter.clear();
2106                layoutManager.setSpanCount(1);
2107
2108                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2109                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2110                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2111                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2112                    }
2113
2114                    for (Element el : command.getChildren()) {
2115                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2116                            for (Element action : el.getChildren()) {
2117                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2118                                if (action.getName().equals("execute")) continue;
2119
2120                                actionsAdapter.add(action.getName());
2121                            }
2122                        }
2123                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2124                            String title = el.findChildContent("title", "jabber:x:data");
2125                            if (title != null) {
2126                                mTitle = title;
2127                                ConversationPagerAdapter.this.notifyDataSetChanged();
2128                            }
2129
2130                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2131                                this.responseElement = el;
2132                                setupReported(el.findChild("reported", "jabber:x:data"));
2133                                layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2134                            }
2135                            break;
2136                        }
2137                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2138                            String url = el.findChildContent("url", "jabber:x:oob");
2139                            if (url != null) {
2140                                String scheme = Uri.parse(url).getScheme();
2141                                if (scheme.equals("http") || scheme.equals("https")) {
2142                                    this.responseElement = el;
2143                                    break;
2144                                }
2145                            }
2146                        }
2147                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2148                            this.responseElement = el;
2149                            break;
2150                        }
2151                    }
2152
2153                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2154                        removeSession(this);
2155                        return;
2156                    }
2157
2158                    if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2159                        // No actions have been given, but we are not done?
2160                        // This is probably a spec violation, but we should do *something*
2161                        actionsAdapter.add("execute");
2162                    }
2163
2164                    if (!actionsAdapter.isEmpty()) {
2165                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2166                            actionsAdapter.add("close");
2167                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2168                            actionsAdapter.insert("cancel", 0);
2169                        }
2170                    }
2171                }
2172
2173                if (actionsAdapter.isEmpty()) {
2174                    actionsAdapter.add("close");
2175                }
2176
2177                notifyDataSetChanged();
2178            }
2179
2180            protected void setupReported(Element el) {
2181                if (el == null) {
2182                    reported = null;
2183                    return;
2184                }
2185
2186                reported = new ArrayList<>();
2187                for (Element fieldEl : el.getChildren()) {
2188                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2189                    reported.add(mkField(fieldEl));
2190                }
2191            }
2192
2193            @Override
2194            public int getItemCount() {
2195                if (loading) return 1;
2196                if (response == null) return 0;
2197                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2198                    int i = 0;
2199                    for (Element el : responseElement.getChildren()) {
2200                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2201                        if (el.getName().equals("title")) continue;
2202                        if (el.getName().equals("field")) {
2203                            String type = el.getAttribute("type");
2204                            if (type != null && type.equals("hidden")) continue;
2205                        }
2206
2207                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2208                            if (reported != null) i += reported.size();
2209                            continue;
2210                        }
2211
2212                        i++;
2213                    }
2214                    return i;
2215                }
2216                return 1;
2217            }
2218
2219            public Item getItem(int position) {
2220                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2221                if (items.get(position) != null) return items.get(position);
2222                if (response == null) return null;
2223
2224                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2225                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2226                        int i = 0;
2227                        for (Element el : responseElement.getChildren()) {
2228                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2229                            if (el.getName().equals("title")) continue;
2230                            if (el.getName().equals("field")) {
2231                                String type = el.getAttribute("type");
2232                                if (type != null && type.equals("hidden")) continue;
2233                            }
2234
2235                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2236                                Cell cell = null;
2237
2238                                if (reported != null) {
2239                                    if (reported.size() > position - i) {
2240                                        Field reportedField = reported.get(position - i);
2241                                        Element itemField = null;
2242                                        if (el.getName().equals("item")) {
2243                                            for (Element subel : el.getChildren()) {
2244                                                if (subel.getAttribute("var").equals(reportedField.getVar())) {
2245                                                   itemField = subel;
2246                                                   break;
2247                                                }
2248                                            }
2249                                        }
2250                                        cell = new Cell(reportedField, itemField);
2251                                    } else {
2252                                        i += reported.size();
2253                                        continue;
2254                                    }
2255                                }
2256
2257                                if (cell != null) {
2258                                    items.put(position, cell);
2259                                    return cell;
2260                                }
2261                            }
2262
2263                            if (i < position) {
2264                                i++;
2265                                continue;
2266                            }
2267
2268                            return mkItem(el, position);
2269                        }
2270                    }
2271                }
2272
2273                return mkItem(responseElement == null ? response : responseElement, position);
2274            }
2275
2276            @Override
2277            public int getItemViewType(int position) {
2278                return getItem(position).viewType;
2279            }
2280
2281            @Override
2282            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2283                switch(viewType) {
2284                    case TYPE_ERROR: {
2285                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2286                        return new ErrorViewHolder(binding);
2287                    }
2288                    case TYPE_NOTE: {
2289                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2290                        return new NoteViewHolder(binding);
2291                    }
2292                    case TYPE_WEB: {
2293                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2294                        return new WebViewHolder(binding);
2295                    }
2296                    case TYPE_RESULT_FIELD: {
2297                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2298                        return new ResultFieldViewHolder(binding);
2299                    }
2300                    case TYPE_RESULT_CELL: {
2301                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2302                        return new ResultCellViewHolder(binding);
2303                    }
2304                    case TYPE_CHECKBOX_FIELD: {
2305                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2306                        return new CheckboxFieldViewHolder(binding);
2307                    }
2308                    case TYPE_SEARCH_LIST_FIELD: {
2309                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2310                        return new SearchListFieldViewHolder(binding);
2311                    }
2312                    case TYPE_RADIO_EDIT_FIELD: {
2313                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2314                        return new RadioEditFieldViewHolder(binding);
2315                    }
2316                    case TYPE_SPINNER_FIELD: {
2317                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2318                        return new SpinnerFieldViewHolder(binding);
2319                    }
2320                    case TYPE_TEXT_FIELD: {
2321                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2322                        return new TextFieldViewHolder(binding);
2323                    }
2324                    case TYPE_PROGRESSBAR: {
2325                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2326                        return new ProgressBarViewHolder(binding);
2327                    }
2328                    default:
2329                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2330                }
2331            }
2332
2333            @Override
2334            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2335                viewHolder.bind(getItem(position));
2336            }
2337
2338            public View getView() {
2339                return mBinding.getRoot();
2340            }
2341
2342            public boolean validate() {
2343                int count = getItemCount();
2344                boolean isValid = true;
2345                for (int i = 0; i < count; i++) {
2346                    boolean oneIsValid = getItem(i).validate();
2347                    isValid = isValid && oneIsValid;
2348                }
2349                notifyDataSetChanged();
2350                return isValid;
2351            }
2352
2353            public boolean execute() {
2354                return execute("execute");
2355            }
2356
2357            public boolean execute(int actionPosition) {
2358                return execute(actionsAdapter.getItem(actionPosition));
2359            }
2360
2361            public boolean execute(String action) {
2362                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2363
2364                if (response == null) return true;
2365                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2366                if (command == null) return true;
2367                String status = command.getAttribute("status");
2368                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2369
2370                if (actionToWebview != null) {
2371                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2372                    return false;
2373                }
2374
2375                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2376                packet.setTo(response.getFrom());
2377                final Element c = packet.addChild("command", Namespace.COMMANDS);
2378                c.setAttribute("node", mNode);
2379                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2380                c.setAttribute("action", action);
2381
2382                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2383                if (!action.equals("cancel") &&
2384                    !action.equals("prev") &&
2385                    responseElement != null &&
2386                    responseElement.getName().equals("x") &&
2387                    responseElement.getNamespace().equals("jabber:x:data") &&
2388                    formType != null && formType.equals("form")) {
2389
2390                    responseElement.setAttribute("type", "submit");
2391                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2392                    if (rsm != null) {
2393                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2394                        max.setContent("1000");
2395                        rsm.addChild(max);
2396                    }
2397                    c.addChild(responseElement);
2398                }
2399
2400                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2401                    getView().post(() -> {
2402                        updateWithResponse(iq);
2403                    });
2404                });
2405
2406                loading();
2407                return false;
2408            }
2409
2410            protected void loading() {
2411                loadingTimer.schedule(new TimerTask() {
2412                    @Override
2413                    public void run() {
2414                        getView().post(() -> {
2415                            loading = true;
2416                            notifyDataSetChanged();
2417                        });
2418                    }
2419                }, 500);
2420            }
2421
2422            protected GridLayoutManager setupLayoutManager() {
2423                layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2424                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2425                    @Override
2426                    public int getSpanSize(int position) {
2427                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2428                        return 1;
2429                    }
2430                });
2431                return layoutManager;
2432            }
2433
2434            public void setBinding(CommandPageBinding b) {
2435                mBinding = b;
2436                // https://stackoverflow.com/a/32350474/8611
2437                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2438                    @Override
2439                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2440                        if(rv.getChildCount() > 0) {
2441                            int[] location = new int[2];
2442                            rv.getLocationOnScreen(location);
2443                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2444                            if (childView instanceof ViewGroup) {
2445                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2446                            }
2447                            if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2448                                int action = e.getAction();
2449                                switch (action) {
2450                                    case MotionEvent.ACTION_DOWN:
2451                                        rv.requestDisallowInterceptTouchEvent(true);
2452                                }
2453                            }
2454                        }
2455
2456                        return false;
2457                    }
2458
2459                    @Override
2460                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2461
2462                    @Override
2463                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2464                });
2465                mBinding.form.setLayoutManager(setupLayoutManager());
2466                mBinding.form.setAdapter(this);
2467                mBinding.actions.setAdapter(actionsAdapter);
2468                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2469                    if (execute(pos)) {
2470                        removeSession(CommandSession.this);
2471                    }
2472                });
2473
2474                actionsAdapter.notifyDataSetChanged();
2475            }
2476
2477            // https://stackoverflow.com/a/36037991/8611
2478            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2479                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2480                    View child = viewGroup.getChildAt(i);
2481                    if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2482                        View foundView = findViewAt((ViewGroup) child, x, y);
2483                        if (foundView != null && foundView.isShown()) {
2484                            return foundView;
2485                        }
2486                    } else {
2487                        int[] location = new int[2];
2488                        child.getLocationOnScreen(location);
2489                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2490                        if (rect.contains((int)x, (int)y)) {
2491                            return child;
2492                        }
2493                    }
2494                }
2495
2496                return null;
2497            }
2498        }
2499    }
2500}