Conversation.java

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