Conversation.java

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