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