Conversation.java

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