Conversation.java

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