Conversation.java

   1package eu.siacs.conversations.entities;
   2
   3import android.content.ContentValues;
   4import android.content.Context;
   5import android.content.DialogInterface;
   6import android.content.Intent;
   7import android.database.Cursor;
   8import android.database.DataSetObserver;
   9import android.graphics.drawable.BitmapDrawable;
  10import android.graphics.drawable.Drawable;
  11import android.graphics.Bitmap;
  12import android.graphics.Canvas;
  13import android.graphics.Rect;
  14import android.net.Uri;
  15import android.telephony.PhoneNumberUtils;
  16import android.text.Editable;
  17import android.text.InputType;
  18import android.text.SpannableStringBuilder;
  19import android.text.Spanned;
  20import android.text.StaticLayout;
  21import android.text.TextPaint;
  22import android.text.TextUtils;
  23import android.text.TextWatcher;
  24import android.view.LayoutInflater;
  25import android.view.MotionEvent;
  26import android.view.Gravity;
  27import android.view.View;
  28import android.view.ViewGroup;
  29import android.widget.AbsListView;
  30import android.widget.ArrayAdapter;
  31import android.widget.AdapterView;
  32import android.widget.Button;
  33import android.widget.CompoundButton;
  34import android.widget.GridLayout;
  35import android.widget.ListView;
  36import android.widget.TextView;
  37import android.widget.Toast;
  38import android.widget.Spinner;
  39import android.webkit.JavascriptInterface;
  40import android.webkit.WebMessage;
  41import android.webkit.WebView;
  42import android.webkit.WebViewClient;
  43import android.webkit.WebChromeClient;
  44import android.util.DisplayMetrics;
  45import android.util.LruCache;
  46import android.util.Pair;
  47import android.util.SparseArray;
  48
  49import androidx.annotation.NonNull;
  50import androidx.annotation.Nullable;
  51import androidx.appcompat.app.AlertDialog;
  52import androidx.appcompat.app.AlertDialog.Builder;
  53import androidx.core.content.ContextCompat;
  54import androidx.databinding.DataBindingUtil;
  55import androidx.databinding.ViewDataBinding;
  56import androidx.viewpager.widget.PagerAdapter;
  57import androidx.recyclerview.widget.RecyclerView;
  58import androidx.recyclerview.widget.GridLayoutManager;
  59import androidx.viewpager.widget.ViewPager;
  60
  61import com.caverock.androidsvg.SVG;
  62
  63import com.cheogram.android.ConversationPage;
  64import com.cheogram.android.Util;
  65import com.cheogram.android.WebxdcPage;
  66
  67import com.google.android.material.tabs.TabLayout;
  68import com.google.android.material.textfield.TextInputLayout;
  69import com.google.common.base.Optional;
  70import com.google.common.collect.ComparisonChain;
  71import com.google.common.collect.Lists;
  72
  73import io.ipfs.cid.Cid;
  74
  75import org.json.JSONArray;
  76import org.json.JSONException;
  77import org.json.JSONObject;
  78
  79import java.time.LocalDateTime;
  80import java.time.ZoneId;
  81import java.time.ZonedDateTime;
  82import java.time.format.DateTimeParseException;
  83import java.time.format.DateTimeFormatter;
  84import java.time.format.FormatStyle;
  85import java.util.ArrayList;
  86import java.util.Collections;
  87import java.util.Iterator;
  88import java.util.HashSet;
  89import java.util.List;
  90import java.util.ListIterator;
  91import java.util.concurrent.atomic.AtomicBoolean;
  92import java.util.stream.Collectors;
  93import java.util.Set;
  94import java.util.Timer;
  95import java.util.TimerTask;
  96
  97import me.saket.bettermovementmethod.BetterLinkMovementMethod;
  98
  99import eu.siacs.conversations.Config;
 100import eu.siacs.conversations.R;
 101import eu.siacs.conversations.crypto.OmemoSetting;
 102import eu.siacs.conversations.crypto.PgpDecryptionService;
 103import eu.siacs.conversations.databinding.CommandButtonGridFieldBinding;
 104import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
 105import eu.siacs.conversations.databinding.CommandItemCardBinding;
 106import eu.siacs.conversations.databinding.CommandNoteBinding;
 107import eu.siacs.conversations.databinding.CommandPageBinding;
 108import eu.siacs.conversations.databinding.CommandProgressBarBinding;
 109import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
 110import eu.siacs.conversations.databinding.CommandResultCellBinding;
 111import eu.siacs.conversations.databinding.CommandResultFieldBinding;
 112import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
 113import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
 114import eu.siacs.conversations.databinding.CommandTextFieldBinding;
 115import eu.siacs.conversations.databinding.CommandWebviewBinding;
 116import eu.siacs.conversations.databinding.DialogQuickeditBinding;
 117import eu.siacs.conversations.http.HttpConnectionManager;
 118import eu.siacs.conversations.persistance.DatabaseBackend;
 119import eu.siacs.conversations.services.AvatarService;
 120import eu.siacs.conversations.services.QuickConversationsService;
 121import eu.siacs.conversations.services.XmppConnectionService;
 122import eu.siacs.conversations.ui.UriHandlerActivity;
 123import eu.siacs.conversations.ui.text.FixedURLSpan;
 124import eu.siacs.conversations.ui.util.ShareUtil;
 125import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 126import eu.siacs.conversations.utils.Consumer;
 127import eu.siacs.conversations.utils.JidHelper;
 128import eu.siacs.conversations.utils.MessageUtils;
 129import eu.siacs.conversations.utils.UIHelper;
 130import eu.siacs.conversations.xml.Element;
 131import eu.siacs.conversations.xml.Namespace;
 132import eu.siacs.conversations.xmpp.Jid;
 133import eu.siacs.conversations.xmpp.chatstate.ChatState;
 134import eu.siacs.conversations.xmpp.forms.Data;
 135import eu.siacs.conversations.xmpp.forms.Option;
 136import eu.siacs.conversations.xmpp.mam.MamReference;
 137import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 138
 139import static eu.siacs.conversations.entities.Bookmark.printableValue;
 140
 141
 142public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
 143    public static final String TABLENAME = "conversations";
 144
 145    public static final int STATUS_AVAILABLE = 0;
 146    public static final int STATUS_ARCHIVED = 1;
 147
 148    public static final String NAME = "name";
 149    public static final String ACCOUNT = "accountUuid";
 150    public static final String CONTACT = "contactUuid";
 151    public static final String CONTACTJID = "contactJid";
 152    public static final String STATUS = "status";
 153    public static final String CREATED = "created";
 154    public static final String MODE = "mode";
 155    public static final String ATTRIBUTES = "attributes";
 156
 157    public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
 158    public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
 159    public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
 160    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
 161    public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
 162    static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
 163    static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
 164    static final String ATTRIBUTE_MODERATED = "moderated";
 165    static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
 166    private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
 167    private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
 168    private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
 169    private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
 170    private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
 171    protected final ArrayList<Message> messages = new ArrayList<>();
 172    public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
 173    protected Account account = null;
 174    private String draftMessage;
 175    private final String name;
 176    private final String contactUuid;
 177    private final String accountUuid;
 178    private Jid contactJid;
 179    private int status;
 180    private final long created;
 181    private int mode;
 182    private JSONObject attributes;
 183    private Jid nextCounterpart;
 184    private transient MucOptions mucOptions = null;
 185    private boolean messagesLeftOnServer = true;
 186    private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
 187    private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
 188    private String mFirstMamReference = null;
 189    protected int mCurrentTab = -1;
 190    protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
 191    protected Element thread = null;
 192    protected boolean lockThread = false;
 193    protected boolean userSelectedThread = false;
 194    protected Message replyTo = null;
 195
 196    public Conversation(final String name, final Account account, final Jid contactJid,
 197                        final int mode) {
 198        this(java.util.UUID.randomUUID().toString(), name, null, account
 199                        .getUuid(), contactJid, System.currentTimeMillis(),
 200                STATUS_AVAILABLE, mode, "");
 201        this.account = account;
 202    }
 203
 204    public Conversation(final String uuid, final String name, final String contactUuid,
 205                        final String accountUuid, final Jid contactJid, final long created, final int status,
 206                        final int mode, final String attributes) {
 207        this.uuid = uuid;
 208        this.name = name;
 209        this.contactUuid = contactUuid;
 210        this.accountUuid = accountUuid;
 211        this.contactJid = contactJid;
 212        this.created = created;
 213        this.status = status;
 214        this.mode = mode;
 215        try {
 216            this.attributes = new JSONObject(attributes == null ? "" : attributes);
 217        } catch (JSONException e) {
 218            this.attributes = new JSONObject();
 219        }
 220    }
 221
 222    public static Conversation fromCursor(Cursor cursor) {
 223        return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
 224                cursor.getString(cursor.getColumnIndex(NAME)),
 225                cursor.getString(cursor.getColumnIndex(CONTACT)),
 226                cursor.getString(cursor.getColumnIndex(ACCOUNT)),
 227                JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
 228                cursor.getLong(cursor.getColumnIndex(CREATED)),
 229                cursor.getInt(cursor.getColumnIndex(STATUS)),
 230                cursor.getInt(cursor.getColumnIndex(MODE)),
 231                cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
 232    }
 233
 234    public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
 235        for (int i = messages.size() - 1; i >= 0; --i) {
 236            final Message message = messages.get(i);
 237            if (message.getStatus() <= Message.STATUS_RECEIVED
 238                    && (message.markable || isPrivateAndNonAnonymousMuc)
 239                    && !message.isPrivateMessage()) {
 240                return message;
 241            }
 242        }
 243        return null;
 244    }
 245
 246    private static boolean suitableForOmemoByDefault(final Conversation conversation) {
 247        if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
 248            return false;
 249        }
 250        if (conversation.getContact().isOwnServer()) {
 251            return false;
 252        }
 253        final String contact = conversation.getJid().getDomain().toEscapedString();
 254        final String account = conversation.getAccount().getServer();
 255        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
 256            return false;
 257        }
 258        return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
 259    }
 260
 261    public boolean hasMessagesLeftOnServer() {
 262        return messagesLeftOnServer;
 263    }
 264
 265    public void setHasMessagesLeftOnServer(boolean value) {
 266        this.messagesLeftOnServer = value;
 267    }
 268
 269    public Message getFirstUnreadMessage() {
 270        Message first = null;
 271        synchronized (this.messages) {
 272            for (int i = messages.size() - 1; i >= 0; --i) {
 273                if (messages.get(i).isRead()) {
 274                    return first;
 275                } else {
 276                    first = messages.get(i);
 277                }
 278            }
 279        }
 280        return first;
 281    }
 282
 283    public String findMostRecentRemoteDisplayableId() {
 284        final boolean multi = mode == Conversation.MODE_MULTI;
 285        synchronized (this.messages) {
 286            for (final Message message : Lists.reverse(this.messages)) {
 287                if (message.getStatus() == Message.STATUS_RECEIVED) {
 288                    final String serverMsgId = message.getServerMsgId();
 289                    if (serverMsgId != null && multi) {
 290                        return serverMsgId;
 291                    }
 292                    return message.getRemoteMsgId();
 293                }
 294            }
 295        }
 296        return null;
 297    }
 298
 299    public int countFailedDeliveries() {
 300        int count = 0;
 301        synchronized (this.messages) {
 302            for(final Message message : this.messages) {
 303                if (message.getStatus() == Message.STATUS_SEND_FAILED) {
 304                    ++count;
 305                }
 306            }
 307        }
 308        return count;
 309    }
 310
 311    public Message getLastEditableMessage() {
 312        synchronized (this.messages) {
 313            for (final Message message : Lists.reverse(this.messages)) {
 314                if (message.isEditable()) {
 315                    if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
 316                        return null;
 317                    }
 318                    return message;
 319                }
 320            }
 321        }
 322        return null;
 323    }
 324
 325
 326    public Message findUnsentMessageWithUuid(String uuid) {
 327        synchronized (this.messages) {
 328            for (final Message message : this.messages) {
 329                final int s = message.getStatus();
 330                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
 331                    return message;
 332                }
 333            }
 334        }
 335        return null;
 336    }
 337
 338    public void findWaitingMessages(OnMessageFound onMessageFound) {
 339        final ArrayList<Message> results = new ArrayList<>();
 340        synchronized (this.messages) {
 341            for (Message message : this.messages) {
 342                if (message.getStatus() == Message.STATUS_WAITING) {
 343                    results.add(message);
 344                }
 345            }
 346        }
 347        for (Message result : results) {
 348            onMessageFound.onMessageFound(result);
 349        }
 350    }
 351
 352    public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
 353        final ArrayList<Message> results = new ArrayList<>();
 354        synchronized (this.messages) {
 355            for (final Message message : this.messages) {
 356                if (message.isRead()) {
 357                    continue;
 358                }
 359                results.add(message);
 360            }
 361        }
 362        for (final Message result : results) {
 363            onMessageFound.onMessageFound(result);
 364        }
 365    }
 366
 367    public Message findMessageWithFileAndUuid(final String uuid) {
 368        synchronized (this.messages) {
 369            for (final Message message : this.messages) {
 370                final Transferable transferable = message.getTransferable();
 371                final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
 372                if (message.getUuid().equals(uuid)
 373                        && message.getEncryption() != Message.ENCRYPTION_PGP
 374                        && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
 375                    return message;
 376                }
 377            }
 378        }
 379        return null;
 380    }
 381
 382    public Message findMessageWithUuid(final String uuid) {
 383        synchronized (this.messages) {
 384            for (final Message message : this.messages) {
 385                if (message.getUuid().equals(uuid)) {
 386                    return message;
 387                }
 388            }
 389        }
 390        return null;
 391    }
 392
 393    public boolean markAsDeleted(final List<String> uuids) {
 394        boolean deleted = false;
 395        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 396        synchronized (this.messages) {
 397            for (Message message : this.messages) {
 398                if (uuids.contains(message.getUuid())) {
 399                    message.setDeleted(true);
 400                    deleted = true;
 401                    if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 402                        pgpDecryptionService.discard(message);
 403                    }
 404                }
 405            }
 406        }
 407        return deleted;
 408    }
 409
 410    public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
 411        boolean changed = false;
 412        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 413        synchronized (this.messages) {
 414            for (Message message : this.messages) {
 415                for (final DatabaseBackend.FilePathInfo file : files)
 416                    if (file.uuid.toString().equals(message.getUuid())) {
 417                        message.setDeleted(file.deleted);
 418                        changed = true;
 419                        if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 420                            pgpDecryptionService.discard(message);
 421                        }
 422                    }
 423            }
 424        }
 425        return changed;
 426    }
 427
 428    public void clearMessages() {
 429        synchronized (this.messages) {
 430            this.messages.clear();
 431        }
 432    }
 433
 434    public boolean setIncomingChatState(ChatState state) {
 435        if (this.mIncomingChatState == state) {
 436            return false;
 437        }
 438        this.mIncomingChatState = state;
 439        return true;
 440    }
 441
 442    public ChatState getIncomingChatState() {
 443        return this.mIncomingChatState;
 444    }
 445
 446    public boolean setOutgoingChatState(ChatState state) {
 447        if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
 448            if (this.mOutgoingChatState != state) {
 449                this.mOutgoingChatState = state;
 450                return true;
 451            }
 452        }
 453        return false;
 454    }
 455
 456    public ChatState getOutgoingChatState() {
 457        return this.mOutgoingChatState;
 458    }
 459
 460    public void trim() {
 461        synchronized (this.messages) {
 462            final int size = messages.size();
 463            final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
 464            if (size > maxsize) {
 465                List<Message> discards = this.messages.subList(0, size - maxsize);
 466                final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 467                if (pgpDecryptionService != null) {
 468                    pgpDecryptionService.discard(discards);
 469                }
 470                discards.clear();
 471                untieMessages();
 472            }
 473        }
 474    }
 475
 476    public void findUnsentTextMessages(OnMessageFound onMessageFound) {
 477        final ArrayList<Message> results = new ArrayList<>();
 478        synchronized (this.messages) {
 479            for (Message message : this.messages) {
 480                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
 481                    results.add(message);
 482                }
 483            }
 484        }
 485        for (Message result : results) {
 486            onMessageFound.onMessageFound(result);
 487        }
 488    }
 489
 490    public Message findSentMessageWithUuidOrRemoteId(String id) {
 491        synchronized (this.messages) {
 492            for (Message message : this.messages) {
 493                if (id.equals(message.getUuid())
 494                        || (message.getStatus() >= Message.STATUS_SEND
 495                        && id.equals(message.getRemoteMsgId()))) {
 496                    return message;
 497                }
 498            }
 499        }
 500        return null;
 501    }
 502
 503    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
 504        synchronized (this.messages) {
 505            for (int i = this.messages.size() - 1; i >= 0; --i) {
 506                final Message message = messages.get(i);
 507                final Jid mcp = message.getCounterpart();
 508                if (mcp == null && counterpart != null) {
 509                    continue;
 510                }
 511                if (counterpart == null || mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
 512                    final boolean idMatch = id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
 513                    if (idMatch) return message;
 514                }
 515            }
 516        }
 517        return null;
 518    }
 519
 520    public Message findSentMessageWithUuid(String id) {
 521        synchronized (this.messages) {
 522            for (Message message : this.messages) {
 523                if (id.equals(message.getUuid())) {
 524                    return message;
 525                }
 526            }
 527        }
 528        return null;
 529    }
 530
 531    public Message findMessageWithRemoteId(String id, Jid counterpart) {
 532        synchronized (this.messages) {
 533            for (Message message : this.messages) {
 534                if (counterpart.equals(message.getCounterpart())
 535                        && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
 536                    return message;
 537                }
 538            }
 539        }
 540        return null;
 541    }
 542
 543    public Message findMessageWithServerMsgId(String id) {
 544        synchronized (this.messages) {
 545            for (Message message : this.messages) {
 546                if (id != null && id.equals(message.getServerMsgId())) {
 547                    return message;
 548                }
 549            }
 550        }
 551        return null;
 552    }
 553
 554    public boolean hasMessageWithCounterpart(Jid counterpart) {
 555        synchronized (this.messages) {
 556            for (Message message : this.messages) {
 557                if (counterpart.equals(message.getCounterpart())) {
 558                    return true;
 559                }
 560            }
 561        }
 562        return false;
 563    }
 564
 565    public Message findMessageReactingTo(String id, Jid reactor) {
 566        if (id == null) return null;
 567
 568        synchronized (this.messages) {
 569            for (int i = this.messages.size() - 1; i >= 0; --i) {
 570                final Message message = messages.get(i);
 571                if (reactor == null && message.getStatus() < Message.STATUS_SEND) continue;
 572                if (reactor != null && !(message.getCounterpart().equals(reactor) || message.getCounterpart().asBareJid().equals(reactor))) continue;
 573
 574                final Element r = message.getReactions();
 575                if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
 576                    return message;
 577                }
 578            }
 579        }
 580        return null;
 581    }
 582
 583    public Set<String> findReactionsTo(String id, Jid reactor) {
 584        Set<String> reactionEmoji = new HashSet<>();
 585        Message reactM = findMessageReactingTo(id, reactor);
 586        Element reactions = reactM == null ? null : reactM.getReactions();
 587        if (reactions != null) {
 588            for (Element el : reactions.getChildren()) {
 589                if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
 590                    reactionEmoji.add(el.getContent());
 591                }
 592            }
 593        }
 594        return reactionEmoji;
 595    }
 596
 597    public long loadMoreTimestamp() {
 598        if (messages.size() < 1) return 0;
 599        if (getLockThread() && messages.size() > 5000) return 0;
 600
 601        if (messages.get(0).getType() == Message.TYPE_STATUS && messages.size() >= 2) {
 602            return messages.get(1).getTimeSent();
 603        } else {
 604            return messages.get(0).getTimeSent();
 605        }
 606    }
 607
 608    public void populateWithMessages(final List<Message> messages) {
 609        synchronized (this.messages) {
 610            messages.clear();
 611            messages.addAll(this.messages);
 612        }
 613        Set<String> extraIds = new HashSet<>();
 614        for (ListIterator<Message> iterator = messages.listIterator(messages.size()); iterator.hasPrevious(); ) {
 615            Message m = iterator.previous();
 616            if (m.wasMergedIntoPrevious() || (getLockThread() && !extraIds.contains(m.replyId()) && (m.getThread() == null || !m.getThread().getContent().equals(getThread().getContent())))) {
 617                iterator.remove();
 618            } else if (getLockThread() && m.getThread() != null) {
 619                Element reply = m.getReply();
 620                if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
 621                Element reactions = m.getReactions();
 622                if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
 623            }
 624        }
 625    }
 626
 627    @Override
 628    public boolean isBlocked() {
 629        return getContact().isBlocked();
 630    }
 631
 632    @Override
 633    public boolean isDomainBlocked() {
 634        return getContact().isDomainBlocked();
 635    }
 636
 637    @Override
 638    public Jid getBlockedJid() {
 639        return getContact().getBlockedJid();
 640    }
 641
 642    public int countMessages() {
 643        synchronized (this.messages) {
 644            return this.messages.size();
 645        }
 646    }
 647
 648    public String getFirstMamReference() {
 649        return this.mFirstMamReference;
 650    }
 651
 652    public void setFirstMamReference(String reference) {
 653        this.mFirstMamReference = reference;
 654    }
 655
 656    public void setLastClearHistory(long time, String reference) {
 657        if (reference != null) {
 658            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
 659        } else {
 660            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
 661        }
 662    }
 663
 664    public MamReference getLastClearHistory() {
 665        return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
 666    }
 667
 668    public List<Jid> getAcceptedCryptoTargets() {
 669        if (mode == MODE_SINGLE) {
 670            return Collections.singletonList(getJid().asBareJid());
 671        } else {
 672            return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
 673        }
 674    }
 675
 676    public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
 677        setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
 678    }
 679
 680    public boolean setCorrectingMessage(Message correctingMessage) {
 681        setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
 682        return correctingMessage == null && draftMessage != null;
 683    }
 684
 685    public Message getCorrectingMessage() {
 686        final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
 687        return uuid == null ? null : findSentMessageWithUuid(uuid);
 688    }
 689
 690    public boolean withSelf() {
 691        return getContact().isSelf();
 692    }
 693
 694    @Override
 695    public int compareTo(@NonNull Conversation another) {
 696        return ComparisonChain.start()
 697                .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
 698                .compare(another.getSortableTime(), getSortableTime())
 699                .result();
 700    }
 701
 702    public long getSortableTime() {
 703        Draft draft = getDraft();
 704        long messageTime = getLatestMessage().getTimeReceived();
 705        if (draft == null) {
 706            return messageTime;
 707        } else {
 708            return Math.max(messageTime, draft.getTimestamp());
 709        }
 710    }
 711
 712    public String getDraftMessage() {
 713        return draftMessage;
 714    }
 715
 716    public void setDraftMessage(String draftMessage) {
 717        this.draftMessage = draftMessage;
 718    }
 719
 720    public Element getThread() {
 721        return this.thread;
 722    }
 723
 724    public void setThread(Element thread) {
 725        this.thread = thread;
 726    }
 727
 728    public void setLockThread(boolean flag) {
 729        this.lockThread = flag;
 730        if (flag) setUserSelectedThread(true);
 731    }
 732
 733    public boolean getLockThread() {
 734        return this.lockThread;
 735    }
 736
 737    public void setUserSelectedThread(boolean flag) {
 738        this.userSelectedThread = flag;
 739    }
 740
 741    public boolean getUserSelectedThread() {
 742        return this.userSelectedThread;
 743    }
 744
 745    public void setReplyTo(Message m) {
 746        this.replyTo = m;
 747    }
 748
 749    public Message getReplyTo() {
 750        return this.replyTo;
 751    }
 752
 753    public boolean isRead() {
 754        synchronized (this.messages) {
 755            for(final Message message : Lists.reverse(this.messages)) {
 756                if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
 757                    continue;
 758                }
 759                return message.isRead();
 760            }
 761            return true;
 762        }
 763    }
 764
 765    public List<Message> markRead(String upToUuid) {
 766        final List<Message> unread = new ArrayList<>();
 767        synchronized (this.messages) {
 768            for (Message message : this.messages) {
 769                if (!message.isRead()) {
 770                    message.markRead();
 771                    unread.add(message);
 772                }
 773                if (message.getUuid().equals(upToUuid)) {
 774                    return unread;
 775                }
 776            }
 777        }
 778        return unread;
 779    }
 780
 781    public Message getLatestMessage() {
 782        synchronized (this.messages) {
 783            if (this.messages.size() == 0) {
 784                Message message = new Message(this, "", Message.ENCRYPTION_NONE);
 785                message.setType(Message.TYPE_STATUS);
 786                message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 787                message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 788                return message;
 789            } else {
 790                return this.messages.get(this.messages.size() - 1);
 791            }
 792        }
 793    }
 794
 795    public @NonNull
 796    CharSequence getName() {
 797        if (getMode() == MODE_MULTI) {
 798            final String roomName = getMucOptions().getName();
 799            final String subject = getMucOptions().getSubject();
 800            final Bookmark bookmark = getBookmark();
 801            final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
 802            if (printableValue(roomName)) {
 803                return roomName;
 804            } else if (printableValue(subject)) {
 805                return subject;
 806            } else if (printableValue(bookmarkName, false)) {
 807                return bookmarkName;
 808            } else {
 809                final String generatedName = getMucOptions().createNameFromParticipants();
 810                if (printableValue(generatedName)) {
 811                    return generatedName;
 812                } else {
 813                    return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
 814                }
 815            }
 816        } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
 817            return contactJid;
 818        } else {
 819            return this.getContact().getDisplayName();
 820        }
 821    }
 822
 823    public String getAccountUuid() {
 824        return this.accountUuid;
 825    }
 826
 827    public Account getAccount() {
 828        return this.account;
 829    }
 830
 831    public void setAccount(final Account account) {
 832        this.account = account;
 833    }
 834
 835    public Contact getContact() {
 836        return this.account.getRoster().getContact(this.contactJid);
 837    }
 838
 839    @Override
 840    public Jid getJid() {
 841        return this.contactJid;
 842    }
 843
 844    public int getStatus() {
 845        return this.status;
 846    }
 847
 848    public void setStatus(int status) {
 849        this.status = status;
 850    }
 851
 852    public long getCreated() {
 853        return this.created;
 854    }
 855
 856    public ContentValues getContentValues() {
 857        ContentValues values = new ContentValues();
 858        values.put(UUID, uuid);
 859        values.put(NAME, name);
 860        values.put(CONTACT, contactUuid);
 861        values.put(ACCOUNT, accountUuid);
 862        values.put(CONTACTJID, contactJid.toString());
 863        values.put(CREATED, created);
 864        values.put(STATUS, status);
 865        values.put(MODE, mode);
 866        synchronized (this.attributes) {
 867            values.put(ATTRIBUTES, attributes.toString());
 868        }
 869        return values;
 870    }
 871
 872    public int getMode() {
 873        return this.mode;
 874    }
 875
 876    public void setMode(int mode) {
 877        this.mode = mode;
 878    }
 879
 880    /**
 881     * short for is Private and Non-anonymous
 882     */
 883    public boolean isSingleOrPrivateAndNonAnonymous() {
 884        return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
 885    }
 886
 887    public boolean isPrivateAndNonAnonymous() {
 888        return getMucOptions().isPrivateAndNonAnonymous();
 889    }
 890
 891    public synchronized MucOptions getMucOptions() {
 892        if (this.mucOptions == null) {
 893            this.mucOptions = new MucOptions(this);
 894        }
 895        return this.mucOptions;
 896    }
 897
 898    public void resetMucOptions() {
 899        this.mucOptions = null;
 900    }
 901
 902    public void setContactJid(final Jid jid) {
 903        this.contactJid = jid;
 904    }
 905
 906    public Jid getNextCounterpart() {
 907        return this.nextCounterpart;
 908    }
 909
 910    public void setNextCounterpart(Jid jid) {
 911        this.nextCounterpart = jid;
 912    }
 913
 914    public int getNextEncryption() {
 915        if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
 916            return Message.ENCRYPTION_NONE;
 917        }
 918        if (OmemoSetting.isAlways()) {
 919            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
 920        }
 921        final int defaultEncryption;
 922        if (suitableForOmemoByDefault(this)) {
 923            defaultEncryption = OmemoSetting.getEncryption();
 924        } else {
 925            defaultEncryption = Message.ENCRYPTION_NONE;
 926        }
 927        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
 928        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
 929            return defaultEncryption;
 930        } else {
 931            return encryption;
 932        }
 933    }
 934
 935    public boolean setNextEncryption(int encryption) {
 936        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
 937    }
 938
 939    public String getNextMessage() {
 940        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 941        return nextMessage == null ? "" : nextMessage;
 942    }
 943
 944    public @Nullable
 945    Draft getDraft() {
 946        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
 947        if (timestamp > getLatestMessage().getTimeSent()) {
 948            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 949            if (!TextUtils.isEmpty(message) && timestamp != 0) {
 950                return new Draft(message, timestamp);
 951            }
 952        }
 953        return null;
 954    }
 955
 956    public boolean setNextMessage(final String input) {
 957        final String message = input == null || input.trim().isEmpty() ? null : input;
 958        boolean changed = !getNextMessage().equals(message);
 959        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
 960        if (changed) {
 961            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
 962        }
 963        return changed;
 964    }
 965
 966    public Bookmark getBookmark() {
 967        return this.account.getBookmark(this.contactJid);
 968    }
 969
 970    public Message findDuplicateMessage(Message message) {
 971        synchronized (this.messages) {
 972            for (int i = this.messages.size() - 1; i >= 0; --i) {
 973                if (this.messages.get(i).similar(message)) {
 974                    return this.messages.get(i);
 975                }
 976            }
 977        }
 978        return null;
 979    }
 980
 981    public boolean hasDuplicateMessage(Message message) {
 982        return findDuplicateMessage(message) != null;
 983    }
 984
 985    public Message findSentMessageWithBody(String body) {
 986        synchronized (this.messages) {
 987            for (int i = this.messages.size() - 1; i >= 0; --i) {
 988                Message message = this.messages.get(i);
 989                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
 990                    String otherBody;
 991                    if (message.hasFileOnRemoteHost()) {
 992                        otherBody = message.getFileParams().url;
 993                    } else {
 994                        otherBody = message.body;
 995                    }
 996                    if (otherBody != null && otherBody.equals(body)) {
 997                        return message;
 998                    }
 999                }
1000            }
1001            return null;
1002        }
1003    }
1004
1005    public Message findRtpSession(final String sessionId, final int s) {
1006        synchronized (this.messages) {
1007            for (int i = this.messages.size() - 1; i >= 0; --i) {
1008                final Message message = this.messages.get(i);
1009                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
1010                    return message;
1011                }
1012            }
1013        }
1014        return null;
1015    }
1016
1017    public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
1018        if (serverMsgId == null || remoteMsgId == null) {
1019            return false;
1020        }
1021        synchronized (this.messages) {
1022            for (Message message : this.messages) {
1023                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
1024                    return true;
1025                }
1026            }
1027        }
1028        return false;
1029    }
1030
1031    public MamReference getLastMessageTransmitted() {
1032        final MamReference lastClear = getLastClearHistory();
1033        MamReference lastReceived = new MamReference(0);
1034        synchronized (this.messages) {
1035            for (int i = this.messages.size() - 1; i >= 0; --i) {
1036                final Message message = this.messages.get(i);
1037                if (message.isPrivateMessage()) {
1038                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
1039                }
1040                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
1041                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
1042                    break;
1043                }
1044            }
1045        }
1046        return MamReference.max(lastClear, lastReceived);
1047    }
1048
1049    public void setMutedTill(long value) {
1050        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
1051    }
1052
1053    public boolean isMuted() {
1054        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
1055    }
1056
1057    public boolean alwaysNotify() {
1058        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
1059    }
1060
1061    public boolean setAttribute(String key, boolean value) {
1062        return setAttribute(key, String.valueOf(value));
1063    }
1064
1065    private boolean setAttribute(String key, long value) {
1066        return setAttribute(key, Long.toString(value));
1067    }
1068
1069    private boolean setAttribute(String key, int value) {
1070        return setAttribute(key, String.valueOf(value));
1071    }
1072
1073    public boolean setAttribute(String key, String value) {
1074        synchronized (this.attributes) {
1075            try {
1076                if (value == null) {
1077                    if (this.attributes.has(key)) {
1078                        this.attributes.remove(key);
1079                        return true;
1080                    } else {
1081                        return false;
1082                    }
1083                } else {
1084                    final String prev = this.attributes.optString(key, null);
1085                    this.attributes.put(key, value);
1086                    return !value.equals(prev);
1087                }
1088            } catch (JSONException e) {
1089                throw new AssertionError(e);
1090            }
1091        }
1092    }
1093
1094    public boolean setAttribute(String key, List<Jid> jids) {
1095        JSONArray array = new JSONArray();
1096        for (Jid jid : jids) {
1097            array.put(jid.asBareJid().toString());
1098        }
1099        synchronized (this.attributes) {
1100            try {
1101                this.attributes.put(key, array);
1102                return true;
1103            } catch (JSONException e) {
1104                return false;
1105            }
1106        }
1107    }
1108
1109    public String getAttribute(String key) {
1110        synchronized (this.attributes) {
1111            return this.attributes.optString(key, null);
1112        }
1113    }
1114
1115    private List<Jid> getJidListAttribute(String key) {
1116        ArrayList<Jid> list = new ArrayList<>();
1117        synchronized (this.attributes) {
1118            try {
1119                JSONArray array = this.attributes.getJSONArray(key);
1120                for (int i = 0; i < array.length(); ++i) {
1121                    try {
1122                        list.add(Jid.of(array.getString(i)));
1123                    } catch (IllegalArgumentException e) {
1124                        //ignored
1125                    }
1126                }
1127            } catch (JSONException e) {
1128                //ignored
1129            }
1130        }
1131        return list;
1132    }
1133
1134    private int getIntAttribute(String key, int defaultValue) {
1135        String value = this.getAttribute(key);
1136        if (value == null) {
1137            return defaultValue;
1138        } else {
1139            try {
1140                return Integer.parseInt(value);
1141            } catch (NumberFormatException e) {
1142                return defaultValue;
1143            }
1144        }
1145    }
1146
1147    public long getLongAttribute(String key, long defaultValue) {
1148        String value = this.getAttribute(key);
1149        if (value == null) {
1150            return defaultValue;
1151        } else {
1152            try {
1153                return Long.parseLong(value);
1154            } catch (NumberFormatException e) {
1155                return defaultValue;
1156            }
1157        }
1158    }
1159
1160    public boolean getBooleanAttribute(String key, boolean defaultValue) {
1161        String value = this.getAttribute(key);
1162        if (value == null) {
1163            return defaultValue;
1164        } else {
1165            return Boolean.parseBoolean(value);
1166        }
1167    }
1168
1169    public void add(Message message) {
1170        synchronized (this.messages) {
1171            this.messages.add(message);
1172        }
1173    }
1174
1175    public void prepend(int offset, Message message) {
1176        synchronized (this.messages) {
1177            this.messages.add(Math.min(offset, this.messages.size()), message);
1178        }
1179    }
1180
1181    public void addAll(int index, List<Message> messages) {
1182        synchronized (this.messages) {
1183            this.messages.addAll(index, messages);
1184        }
1185        account.getPgpDecryptionService().decrypt(messages);
1186    }
1187
1188    public void expireOldMessages(long timestamp) {
1189        synchronized (this.messages) {
1190            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1191                if (iterator.next().getTimeSent() < timestamp) {
1192                    iterator.remove();
1193                }
1194            }
1195            untieMessages();
1196        }
1197    }
1198
1199    public void sort() {
1200        synchronized (this.messages) {
1201            Collections.sort(this.messages, (left, right) -> {
1202                if (left.getTimeSent() < right.getTimeSent()) {
1203                    return -1;
1204                } else if (left.getTimeSent() > right.getTimeSent()) {
1205                    return 1;
1206                } else {
1207                    return 0;
1208                }
1209            });
1210            untieMessages();
1211        }
1212    }
1213
1214    private void untieMessages() {
1215        for (Message message : this.messages) {
1216            message.untie();
1217        }
1218    }
1219
1220    public int unreadCount() {
1221        synchronized (this.messages) {
1222            int count = 0;
1223            for(final Message message : Lists.reverse(this.messages)) {
1224                if (message.isRead()) {
1225                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1226                        continue;
1227                    }
1228                    return count;
1229                }
1230                ++count;
1231            }
1232            return count;
1233        }
1234    }
1235
1236    public int receivedMessagesCount() {
1237        int count = 0;
1238        synchronized (this.messages) {
1239            for (Message message : messages) {
1240                if (message.getStatus() == Message.STATUS_RECEIVED) {
1241                    ++count;
1242                }
1243            }
1244        }
1245        return count;
1246    }
1247
1248    public int sentMessagesCount() {
1249        int count = 0;
1250        synchronized (this.messages) {
1251            for (Message message : messages) {
1252                if (message.getStatus() != Message.STATUS_RECEIVED) {
1253                    ++count;
1254                }
1255            }
1256        }
1257        return count;
1258    }
1259
1260    public boolean canInferPresence() {
1261        final Contact contact = getContact();
1262        if (contact != null && contact.canInferPresence()) return true;
1263        return sentMessagesCount() > 0;
1264    }
1265
1266    public boolean isWithStranger() {
1267        final Contact contact = getContact();
1268        return mode == MODE_SINGLE
1269                && !contact.isOwnServer()
1270                && !contact.showInContactList()
1271                && !contact.isSelf()
1272                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1273                && sentMessagesCount() == 0;
1274    }
1275
1276    public int getReceivedMessagesCountSinceUuid(String uuid) {
1277        if (uuid == null) {
1278            return 0;
1279        }
1280        int count = 0;
1281        synchronized (this.messages) {
1282            for (int i = messages.size() - 1; i >= 0; i--) {
1283                final Message message = messages.get(i);
1284                if (uuid.equals(message.getUuid())) {
1285                    return count;
1286                }
1287                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1288                    ++count;
1289                }
1290            }
1291        }
1292        return 0;
1293    }
1294
1295    @Override
1296    public int getAvatarBackgroundColor() {
1297        return UIHelper.getColorForName(getName().toString());
1298    }
1299
1300    @Override
1301    public String getAvatarName() {
1302        return getName().toString();
1303    }
1304
1305    public void setCurrentTab(int tab) {
1306        mCurrentTab = tab;
1307    }
1308
1309    public int getCurrentTab() {
1310        if (mCurrentTab >= 0) return mCurrentTab;
1311
1312        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1313            return 0;
1314        }
1315
1316        return 1;
1317    }
1318
1319    public void refreshSessions() {
1320        pagerAdapter.refreshSessions();
1321    }
1322
1323    public void startWebxdc(WebxdcPage page) {
1324        pagerAdapter.startWebxdc(page);
1325    }
1326
1327    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1328        pagerAdapter.startCommand(command, xmppConnectionService);
1329    }
1330
1331    public boolean switchToSession(final String node) {
1332        return pagerAdapter.switchToSession(node);
1333    }
1334
1335    public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1336        pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1337    }
1338
1339    public void showViewPager() {
1340        pagerAdapter.show();
1341    }
1342
1343    public void hideViewPager() {
1344        pagerAdapter.hide();
1345    }
1346
1347    public interface OnMessageFound {
1348        void onMessageFound(final Message message);
1349    }
1350
1351    public static class Draft {
1352        private final String message;
1353        private final long timestamp;
1354
1355        private Draft(String message, long timestamp) {
1356            this.message = message;
1357            this.timestamp = timestamp;
1358        }
1359
1360        public long getTimestamp() {
1361            return timestamp;
1362        }
1363
1364        public String getMessage() {
1365            return message;
1366        }
1367    }
1368
1369    public class ConversationPagerAdapter extends PagerAdapter {
1370        protected ViewPager mPager = null;
1371        protected TabLayout mTabs = null;
1372        ArrayList<ConversationPage> sessions = null;
1373        protected View page1 = null;
1374        protected View page2 = null;
1375        protected boolean mOnboarding = false;
1376
1377        public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1378            mPager = pager;
1379            mTabs = tabs;
1380            mOnboarding = onboarding;
1381
1382            if (oldConversation != null) {
1383                oldConversation.pagerAdapter.mPager = null;
1384                oldConversation.pagerAdapter.mTabs = null;
1385            }
1386
1387            if (mPager == null) return;
1388            if (sessions != null) show();
1389
1390            if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1391            if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1392            if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1393            if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1394            if (page1 == null || page2 == null) {
1395                throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1396            }
1397            pager.removeView(page1);
1398            pager.removeView(page2);
1399            pager.setAdapter(this);
1400            tabs.setupWithViewPager(mPager);
1401            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1402
1403            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1404                public void onPageScrollStateChanged(int state) { }
1405                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1406
1407                public void onPageSelected(int position) {
1408                    setCurrentTab(position);
1409                }
1410            });
1411        }
1412
1413        public void show() {
1414            if (sessions == null) {
1415                sessions = new ArrayList<>();
1416                notifyDataSetChanged();
1417            }
1418            if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1419        }
1420
1421        public void hide() {
1422            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1423            if (mPager != null) mPager.setCurrentItem(0);
1424            if (mTabs != null) mTabs.setVisibility(View.GONE);
1425            sessions = null;
1426            notifyDataSetChanged();
1427        }
1428
1429        public void refreshSessions() {
1430            if (sessions == null) return;
1431
1432            for (ConversationPage session : sessions) {
1433                session.refresh();
1434            }
1435        }
1436
1437        public void startWebxdc(WebxdcPage page) {
1438            show();
1439            sessions.add(page);
1440            notifyDataSetChanged();
1441            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1442        }
1443
1444        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1445            show();
1446            CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1447
1448            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1449            packet.setTo(command.getAttributeAsJid("jid"));
1450            final Element c = packet.addChild("command", Namespace.COMMANDS);
1451            c.setAttribute("node", command.getAttribute("node"));
1452            c.setAttribute("action", "execute");
1453
1454            final TimerTask task = new TimerTask() {
1455                @Override
1456                public void run() {
1457                    if (getAccount().getStatus() != Account.State.ONLINE) {
1458                        final TimerTask self = this;
1459                        new Timer().schedule(new TimerTask() {
1460                            @Override
1461                            public void run() {
1462                                self.run();
1463                            }
1464                        }, 1000);
1465                    } else {
1466                        xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1467                            session.updateWithResponse(iq);
1468                        });
1469                    }
1470                }
1471            };
1472
1473            if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1474                new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1475                    if (signedData != null && signature != null) {
1476                        c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1477                        c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1478                    }
1479
1480                    task.run();
1481                }).checkLicense();
1482            } else {
1483                task.run();
1484            }
1485
1486            sessions.add(session);
1487            notifyDataSetChanged();
1488            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1489        }
1490
1491        public void removeSession(ConversationPage session) {
1492            sessions.remove(session);
1493            notifyDataSetChanged();
1494            if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1495        }
1496
1497        public boolean switchToSession(final String node) {
1498            if (sessions == null) return false;
1499
1500            int i = 0;
1501            for (ConversationPage session : sessions) {
1502                if (session.getNode().equals(node)) {
1503                    if (mPager != null) mPager.setCurrentItem(i + 2);
1504                    return true;
1505                }
1506                i++;
1507            }
1508
1509            return false;
1510        }
1511
1512        @NonNull
1513        @Override
1514        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1515            if (position == 0) {
1516                if (page1 != null && page1.getParent() != null) {
1517                    ((ViewGroup) page1.getParent()).removeView(page1);
1518                }
1519                container.addView(page1);
1520                return page1;
1521            }
1522            if (position == 1) {
1523                if (page2 != null && page2.getParent() != null) {
1524                    ((ViewGroup) page2.getParent()).removeView(page2);
1525                }
1526                container.addView(page2);
1527                return page2;
1528            }
1529
1530            ConversationPage session = sessions.get(position-2);
1531            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1532            if (v != null && v.getParent() != null) {
1533                ((ViewGroup) v.getParent()).removeView(v);
1534            }
1535            container.addView(v);
1536            return session;
1537        }
1538
1539        @Override
1540        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1541            if (position < 2) {
1542                container.removeView((View) o);
1543                return;
1544            }
1545
1546            container.removeView(((ConversationPage) o).getView());
1547        }
1548
1549        @Override
1550        public int getItemPosition(Object o) {
1551            if (mPager != null) {
1552                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1553                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1554            }
1555
1556            int pos = sessions == null ? -1 : sessions.indexOf(o);
1557            if (pos < 0) return PagerAdapter.POSITION_NONE;
1558            return pos + 2;
1559        }
1560
1561        @Override
1562        public int getCount() {
1563            if (sessions == null) return 1;
1564
1565            int count = 2 + sessions.size();
1566            if (mTabs == null) return count;
1567
1568            if (count > 2) {
1569                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1570            } else {
1571                mTabs.setTabMode(TabLayout.MODE_FIXED);
1572            }
1573            return count;
1574        }
1575
1576        @Override
1577        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1578            if (view == o) return true;
1579
1580            if (o instanceof ConversationPage) {
1581                return ((ConversationPage) o).getView() == view;
1582            }
1583
1584            return false;
1585        }
1586
1587        @Nullable
1588        @Override
1589        public CharSequence getPageTitle(int position) {
1590            switch (position) {
1591                case 0:
1592                    return "Conversation";
1593                case 1:
1594                    return "Commands";
1595                default:
1596                    ConversationPage session = sessions.get(position-2);
1597                    if (session == null) return super.getPageTitle(position);
1598                    return session.getTitle();
1599            }
1600        }
1601
1602        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1603            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1604                protected T binding;
1605
1606                public ViewHolder(T binding) {
1607                    super(binding.getRoot());
1608                    this.binding = binding;
1609                }
1610
1611                abstract public void bind(Item el);
1612
1613                protected void setTextOrHide(TextView v, Optional<String> s) {
1614                    if (s == null || !s.isPresent()) {
1615                        v.setVisibility(View.GONE);
1616                    } else {
1617                        v.setVisibility(View.VISIBLE);
1618                        v.setText(s.get());
1619                    }
1620                }
1621
1622                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1623                    int flags = 0;
1624                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1625                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1626
1627                    String type = field.getAttribute("type");
1628                    if (type != null) {
1629                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1630                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1631                        }
1632
1633                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1634
1635                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1636                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1637                        }
1638
1639                        if (type.equals("text-private")) {
1640                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1641                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1642                        }
1643                    }
1644
1645                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1646                    if (validate == null) return;
1647                    String datatype = validate.getAttribute("datatype");
1648                    if (datatype == null) return;
1649
1650                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1651                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1652                    }
1653
1654                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1655                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1656                    }
1657
1658                    if (datatype.equals("xs:date")) {
1659                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1660                    }
1661
1662                    if (datatype.equals("xs:dateTime")) {
1663                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1664                    }
1665
1666                    if (datatype.equals("xs:time")) {
1667                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1668                    }
1669
1670                    if (datatype.equals("xs:anyURI")) {
1671                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1672                    }
1673
1674                    if (datatype.equals("html:tel")) {
1675                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1676                    }
1677
1678                    if (datatype.equals("html:email")) {
1679                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1680                    }
1681                }
1682
1683                protected String formatValue(String datatype, String value, boolean compact) {
1684                    if ("xs:dateTime".equals(datatype)) {
1685                        ZonedDateTime zonedDateTime = null;
1686                        try {
1687                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1688                        } catch (final DateTimeParseException e) {
1689                            try {
1690                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1691                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
1692                            } catch (final DateTimeParseException e2) { }
1693                        }
1694                        if (zonedDateTime == null) return value;
1695                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1696                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1697                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
1698                    }
1699
1700                    if ("html:tel".equals(datatype) && !compact) {
1701                        return PhoneNumberUtils.formatNumber(value, value, null);
1702                    }
1703
1704                    return value;
1705                }
1706            }
1707
1708            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1709                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1710
1711                @Override
1712                public void bind(Item iq) {
1713                    binding.errorIcon.setVisibility(View.VISIBLE);
1714
1715                    Element error = iq.el.findChild("error");
1716                    if (error == null) return;
1717                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1718                    if (text == null || text.equals("")) {
1719                        text = error.getChildren().get(0).getName();
1720                    }
1721                    binding.message.setText(text);
1722                }
1723            }
1724
1725            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1726                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1727
1728                @Override
1729                public void bind(Item note) {
1730                    binding.message.setText(note.el.getContent());
1731
1732                    String type = note.el.getAttribute("type");
1733                    if (type != null && type.equals("error")) {
1734                        binding.errorIcon.setVisibility(View.VISIBLE);
1735                    }
1736                }
1737            }
1738
1739            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1740                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1741
1742                @Override
1743                public void bind(Item item) {
1744                    Field field = (Field) item;
1745                    setTextOrHide(binding.label, field.getLabel());
1746                    setTextOrHide(binding.desc, field.getDesc());
1747
1748                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
1749                    if (media == null) {
1750                        binding.mediaImage.setVisibility(View.GONE);
1751                    } else {
1752                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1753                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1754                        for (Element uriEl : media.getChildren()) {
1755                            if (!"uri".equals(uriEl.getName())) continue;
1756                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1757                            String mimeType = uriEl.getAttribute("type");
1758                            String uriS = uriEl.getContent();
1759                            if (mimeType == null || uriS == null) continue;
1760                            Uri uri = Uri.parse(uriS);
1761                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1762                                final Drawable d = cache.get(uri.toString());
1763                                if (d == null) {
1764                                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
1765                                    Message dummy = new Message(Conversation.this, uri.toString(), Message.ENCRYPTION_NONE);
1766                                    dummy.setFileParams(new Message.FileParams(uri.toString()));
1767                                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
1768                                        if (file == null) {
1769                                            dummy.getTransferable().start();
1770                                        } else {
1771                                            try {
1772                                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, uri.toString());
1773                                            } catch (final Exception e) { }
1774                                        }
1775                                    });
1776                                } else {
1777                                    binding.mediaImage.setImageDrawable(d);
1778                                    binding.mediaImage.setVisibility(View.VISIBLE);
1779                                }
1780                            }
1781                        }
1782                    }
1783
1784                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1785                    String datatype = validate == null ? null : validate.getAttribute("datatype");
1786
1787                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1788                    for (Element el : field.el.getChildren()) {
1789                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1790                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1791                        }
1792                    }
1793                    binding.values.setAdapter(values);
1794                    Util.justifyListViewHeightBasedOnChildren(binding.values);
1795
1796                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1797                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1798                            new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString()).onClick(binding.values);
1799                        });
1800                    }
1801
1802                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1803                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1804                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1805                        }
1806                        return true;
1807                    });
1808                }
1809            }
1810
1811            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1812                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1813
1814                @Override
1815                public void bind(Item item) {
1816                    Cell cell = (Cell) item;
1817
1818                    if (cell.el == null) {
1819                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1820                        setTextOrHide(binding.text, cell.reported.getLabel());
1821                    } else {
1822                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1823                        String datatype = validate == null ? null : validate.getAttribute("datatype");
1824                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1825                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1826                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1827                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1828                        }
1829
1830                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1831                        binding.text.setText(text);
1832
1833                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1834                        method.setOnLinkLongClickListener((tv, url) -> {
1835                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1836                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1837                            return true;
1838                        });
1839                        binding.text.setMovementMethod(method);
1840                    }
1841                }
1842            }
1843
1844            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1845                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1846
1847                @Override
1848                public void bind(Item item) {
1849                    binding.fields.removeAllViews();
1850
1851                    for (Field field : reported) {
1852                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1853                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1854                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1855                        param.width = 0;
1856                        row.getRoot().setLayoutParams(param);
1857                        binding.fields.addView(row.getRoot());
1858                        for (Element el : item.el.getChildren()) {
1859                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1860                                for (String label : field.getLabel().asSet()) {
1861                                    el.setAttribute("label", label);
1862                                }
1863                                for (String desc : field.getDesc().asSet()) {
1864                                    el.setAttribute("desc", desc);
1865                                }
1866                                for (String type : field.getType().asSet()) {
1867                                    el.setAttribute("type", type);
1868                                }
1869                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1870                                if (validate != null) el.addChild(validate);
1871                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1872                            }
1873                        }
1874                    }
1875                }
1876            }
1877
1878            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1879                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1880                    super(binding);
1881                    binding.row.setOnClickListener((v) -> {
1882                        binding.checkbox.toggle();
1883                    });
1884                    binding.checkbox.setOnCheckedChangeListener(this);
1885                }
1886                protected Element mValue = null;
1887
1888                @Override
1889                public void bind(Item item) {
1890                    Field field = (Field) item;
1891                    binding.label.setText(field.getLabel().or(""));
1892                    setTextOrHide(binding.desc, field.getDesc());
1893                    mValue = field.getValue();
1894                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1895                }
1896
1897                @Override
1898                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1899                    if (mValue == null) return;
1900
1901                    mValue.setContent(isChecked ? "true" : "false");
1902                }
1903            }
1904
1905            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1906                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1907                    super(binding);
1908                    binding.search.addTextChangedListener(this);
1909                }
1910                protected Element mValue = null;
1911                List<Option> options = new ArrayList<>();
1912                protected ArrayAdapter<Option> adapter;
1913                protected boolean open;
1914
1915                @Override
1916                public void bind(Item item) {
1917                    Field field = (Field) item;
1918                    setTextOrHide(binding.label, field.getLabel());
1919                    setTextOrHide(binding.desc, field.getDesc());
1920
1921                    if (field.error != null) {
1922                        binding.desc.setVisibility(View.VISIBLE);
1923                        binding.desc.setText(field.error);
1924                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1925                    } else {
1926                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1927                    }
1928
1929                    mValue = field.getValue();
1930
1931                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1932                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1933                    setupInputType(field.el, binding.search, null);
1934
1935                    options = field.getOptions();
1936                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1937                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1938                        if (open) binding.search.setText(mValue.getContent());
1939                    });
1940                    search("");
1941                }
1942
1943                @Override
1944                public void afterTextChanged(Editable s) {
1945                    if (open) mValue.setContent(s.toString());
1946                    search(s.toString());
1947                }
1948
1949                @Override
1950                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1951
1952                @Override
1953                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1954
1955                protected void search(String s) {
1956                    List<Option> filteredOptions;
1957                    final String q = s.replaceAll("\\W", "").toLowerCase();
1958                    if (q == null || q.equals("")) {
1959                        filteredOptions = options;
1960                    } else {
1961                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1962                    }
1963                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1964                    binding.list.setAdapter(adapter);
1965
1966                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1967                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1968                }
1969            }
1970
1971            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1972                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1973                    super(binding);
1974                    binding.open.addTextChangedListener(this);
1975                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1976                        @Override
1977                        public View getView(int position, View convertView, ViewGroup parent) {
1978                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1979                            v.setId(position);
1980                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1981                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1982                            return v;
1983                        }
1984                    };
1985                }
1986                protected Element mValue = null;
1987                protected ArrayAdapter<Option> options;
1988
1989                @Override
1990                public void bind(Item item) {
1991                    Field field = (Field) item;
1992                    setTextOrHide(binding.label, field.getLabel());
1993                    setTextOrHide(binding.desc, field.getDesc());
1994
1995                    if (field.error != null) {
1996                        binding.desc.setVisibility(View.VISIBLE);
1997                        binding.desc.setText(field.error);
1998                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1999                    } else {
2000                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2001                    }
2002
2003                    mValue = field.getValue();
2004
2005                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2006                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2007                    binding.open.setText(mValue.getContent());
2008                    setupInputType(field.el, binding.open, null);
2009
2010                    options.clear();
2011                    List<Option> theOptions = field.getOptions();
2012                    options.addAll(theOptions);
2013
2014                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2015                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2016                    float maxColumnWidth = theOptions.stream().map((x) ->
2017                        StaticLayout.getDesiredWidth(x.toString(), paint)
2018                    ).max(Float::compare).orElse(new Float(0.0));
2019                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2020                        binding.radios.setNumColumns(theOptions.size());
2021                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2022                        binding.radios.setNumColumns(theOptions.size() / 2);
2023                    } else {
2024                        binding.radios.setNumColumns(1);
2025                    }
2026                    binding.radios.setAdapter(options);
2027                }
2028
2029                @Override
2030                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2031                    if (mValue == null) return;
2032
2033                    if (isChecked) {
2034                        mValue.setContent(options.getItem(radio.getId()).getValue());
2035                        binding.open.setText(mValue.getContent());
2036                    }
2037                    options.notifyDataSetChanged();
2038                }
2039
2040                @Override
2041                public void afterTextChanged(Editable s) {
2042                    if (mValue == null) return;
2043
2044                    mValue.setContent(s.toString());
2045                    options.notifyDataSetChanged();
2046                }
2047
2048                @Override
2049                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2050
2051                @Override
2052                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2053            }
2054
2055            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2056                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2057                    super(binding);
2058                    binding.spinner.setOnItemSelectedListener(this);
2059                }
2060                protected Element mValue = null;
2061
2062                @Override
2063                public void bind(Item item) {
2064                    Field field = (Field) item;
2065                    setTextOrHide(binding.label, field.getLabel());
2066                    binding.spinner.setPrompt(field.getLabel().or(""));
2067                    setTextOrHide(binding.desc, field.getDesc());
2068
2069                    mValue = field.getValue();
2070
2071                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2072                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2073                    options.addAll(field.getOptions());
2074
2075                    binding.spinner.setAdapter(options);
2076                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2077                }
2078
2079                @Override
2080                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2081                    Option o = (Option) parent.getItemAtPosition(pos);
2082                    if (mValue == null) return;
2083
2084                    mValue.setContent(o == null ? "" : o.getValue());
2085                }
2086
2087                @Override
2088                public void onNothingSelected(AdapterView<?> parent) {
2089                    mValue.setContent("");
2090                }
2091            }
2092
2093            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2094                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2095                    super(binding);
2096                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2097                        @Override
2098                        public View getView(int position, View convertView, ViewGroup parent) {
2099                            Button v = (Button) super.getView(position, convertView, parent);
2100                            v.setOnClickListener((view) -> {
2101                                loading = true;
2102                                mValue.setContent(getItem(position).getValue());
2103                                execute();
2104                            });
2105
2106                            final SVG icon = getItem(position).getIcon();
2107                            if (icon != null) {
2108                                 v.post(() -> {
2109                                     if (v.getHeight() == 0) return;
2110                                     icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2111                                     Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
2112                                     Canvas bmcanvas = new Canvas(bitmap);
2113                                     icon.renderToCanvas(bmcanvas);
2114                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
2115                                 });
2116                            }
2117
2118                            return v;
2119                        }
2120                    };
2121                }
2122                protected Element mValue = null;
2123                protected ArrayAdapter<Option> options;
2124                protected Option defaultOption = null;
2125
2126                @Override
2127                public void bind(Item item) {
2128                    Field field = (Field) item;
2129                    setTextOrHide(binding.label, field.getLabel());
2130                    setTextOrHide(binding.desc, field.getDesc());
2131
2132                    if (field.error != null) {
2133                        binding.desc.setVisibility(View.VISIBLE);
2134                        binding.desc.setText(field.error);
2135                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2136                    } else {
2137                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2138                    }
2139
2140                    mValue = field.getValue();
2141
2142                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2143                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2144                    binding.openButton.setOnClickListener((view) -> {
2145                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2146                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2147                        builder.setPositiveButton(R.string.action_execute, null);
2148                        if (field.getDesc().isPresent()) {
2149                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2150                        }
2151                        dialogBinding.inputEditText.requestFocus();
2152                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2153                        builder.setView(dialogBinding.getRoot());
2154                        builder.setNegativeButton(R.string.cancel, null);
2155                        final AlertDialog dialog = builder.create();
2156                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2157                        dialog.show();
2158                        View.OnClickListener clickListener = v -> {
2159                            loading = true;
2160                            String value = dialogBinding.inputEditText.getText().toString();
2161                            mValue.setContent(value);
2162                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2163                            dialog.dismiss();
2164                            execute();
2165                        };
2166                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2167                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2168                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2169                            dialog.dismiss();
2170                        }));
2171                        dialog.setCanceledOnTouchOutside(false);
2172                        dialog.setOnDismissListener(dialog1 -> {
2173                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2174                        });
2175                    });
2176
2177                    options.clear();
2178                    List<Option> theOptions = field.getType().equals(Optional.of("boolean")) ? new ArrayList<>(List.of(new Option("false", binding.getRoot().getContext().getString(R.string.no)), new Option("true", binding.getRoot().getContext().getString(R.string.yes)))) : field.getOptions();
2179
2180                    defaultOption = null;
2181                    for (Option option : theOptions) {
2182                        if (option.getValue().equals(mValue.getContent())) {
2183                            defaultOption = option;
2184                            break;
2185                        }
2186                    }
2187                    if (defaultOption == null && !mValue.getContent().equals("")) {
2188                        // Synthesize default option for custom value
2189                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2190                    }
2191                    if (defaultOption == null) {
2192                        binding.defaultButton.setVisibility(View.GONE);
2193                    } else {
2194                        theOptions.remove(defaultOption);
2195                        binding.defaultButton.setVisibility(View.VISIBLE);
2196
2197                        final SVG defaultIcon = defaultOption.getIcon();
2198                        if (defaultIcon != null) {
2199                             defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2200                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2201                             Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2202                             bitmap.setDensity(display.densityDpi);
2203                             Canvas bmcanvas = new Canvas(bitmap);
2204                             defaultIcon.renderToCanvas(bmcanvas);
2205                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2206                        }
2207
2208                        binding.defaultButton.setText(defaultOption.toString());
2209                        binding.defaultButton.setOnClickListener((view) -> {
2210                            loading = true;
2211                            mValue.setContent(defaultOption.getValue());
2212                            execute();
2213                        });
2214                    }
2215
2216                    options.addAll(theOptions);
2217                    binding.buttons.setAdapter(options);
2218                }
2219            }
2220
2221            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2222                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2223                    super(binding);
2224                    binding.textinput.addTextChangedListener(this);
2225                }
2226                protected Field field = null;
2227
2228                @Override
2229                public void bind(Item item) {
2230                    field = (Field) item;
2231                    binding.textinputLayout.setHint(field.getLabel().or(""));
2232
2233                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2234                    for (String desc : field.getDesc().asSet()) {
2235                        binding.textinputLayout.setHelperText(desc);
2236                    }
2237
2238                    binding.textinputLayout.setErrorEnabled(field.error != null);
2239                    if (field.error != null) binding.textinputLayout.setError(field.error);
2240
2241                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2242                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2243                    if (suffixLabel == null) {
2244                        binding.textinputLayout.setSuffixText("");
2245                    } else {
2246                        binding.textinputLayout.setSuffixText(suffixLabel);
2247                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2248                    }
2249
2250                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2251                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2252
2253                    binding.textinput.setText(String.join("\n", field.getValues()));
2254                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2255                }
2256
2257                @Override
2258                public void afterTextChanged(Editable s) {
2259                    if (field == null) return;
2260
2261                    field.setValues(List.of(s.toString().split("\n")));
2262                }
2263
2264                @Override
2265                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2266
2267                @Override
2268                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2269            }
2270
2271            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2272                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2273                protected String boundUrl = "";
2274
2275                @Override
2276                public void bind(Item oob) {
2277                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2278                    binding.webview.getSettings().setJavaScriptEnabled(true);
2279                    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");
2280                    binding.webview.getSettings().setDatabaseEnabled(true);
2281                    binding.webview.getSettings().setDomStorageEnabled(true);
2282                    binding.webview.setWebChromeClient(new WebChromeClient() {
2283                        @Override
2284                        public void onProgressChanged(WebView view, int newProgress) {
2285                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2286                            binding.progressbar.setProgress(newProgress);
2287                        }
2288                    });
2289                    binding.webview.setWebViewClient(new WebViewClient() {
2290                        @Override
2291                        public void onPageFinished(WebView view, String url) {
2292                            super.onPageFinished(view, url);
2293                            mTitle = view.getTitle();
2294                            ConversationPagerAdapter.this.notifyDataSetChanged();
2295                        }
2296                    });
2297                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2298                    if (!boundUrl.equals(url)) {
2299                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2300                        binding.webview.loadUrl(url);
2301                        boundUrl = url;
2302                    }
2303                }
2304
2305                class JsObject {
2306                    @JavascriptInterface
2307                    public void execute() { execute("execute"); }
2308
2309                    @JavascriptInterface
2310                    public void execute(String action) {
2311                        getView().post(() -> {
2312                            actionToWebview = null;
2313                            if(CommandSession.this.execute(action)) {
2314                                removeSession(CommandSession.this);
2315                            }
2316                        });
2317                    }
2318
2319                    @JavascriptInterface
2320                    public void preventDefault() {
2321                        actionToWebview = binding.webview;
2322                    }
2323                }
2324            }
2325
2326            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2327                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2328
2329                @Override
2330                public void bind(Item item) { }
2331            }
2332
2333            class Item {
2334                protected Element el;
2335                protected int viewType;
2336                protected String error = null;
2337
2338                Item(Element el, int viewType) {
2339                    this.el = el;
2340                    this.viewType = viewType;
2341                }
2342
2343                public boolean validate() {
2344                    error = null;
2345                    return true;
2346                }
2347            }
2348
2349            class Field extends Item {
2350                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2351
2352                @Override
2353                public boolean validate() {
2354                    if (!super.validate()) return false;
2355                    if (el.findChild("required", "jabber:x:data") == null) return true;
2356                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2357
2358                    error = "this value is required";
2359                    return false;
2360                }
2361
2362                public String getVar() {
2363                    return el.getAttribute("var");
2364                }
2365
2366                public Optional<String> getType() {
2367                    return Optional.fromNullable(el.getAttribute("type"));
2368                }
2369
2370                public Optional<String> getLabel() {
2371                    String label = el.getAttribute("label");
2372                    if (label == null) label = getVar();
2373                    return Optional.fromNullable(label);
2374                }
2375
2376                public Optional<String> getDesc() {
2377                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2378                }
2379
2380                public Element getValue() {
2381                    Element value = el.findChild("value", "jabber:x:data");
2382                    if (value == null) {
2383                        value = el.addChild("value", "jabber:x:data");
2384                    }
2385                    return value;
2386                }
2387
2388                public void setValues(List<String> values) {
2389                    for(Element child : el.getChildren()) {
2390                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2391                            el.removeChild(child);
2392                        }
2393                    }
2394
2395                    for (String value : values) {
2396                        el.addChild("value", "jabber:x:data").setContent(value);
2397                    }
2398                }
2399
2400                public List<String> getValues() {
2401                    List<String> values = new ArrayList<>();
2402                    for(Element child : el.getChildren()) {
2403                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2404                            values.add(child.getContent());
2405                        }
2406                    }
2407                    return values;
2408                }
2409
2410                public List<Option> getOptions() {
2411                    return Option.forField(el);
2412                }
2413            }
2414
2415            class Cell extends Item {
2416                protected Field reported;
2417
2418                Cell(Field reported, Element item) {
2419                    super(item, TYPE_RESULT_CELL);
2420                    this.reported = reported;
2421                }
2422            }
2423
2424            protected Field mkField(Element el) {
2425                int viewType = -1;
2426
2427                String formType = responseElement.getAttribute("type");
2428                if (formType != null) {
2429                    String fieldType = el.getAttribute("type");
2430                    if (fieldType == null) fieldType = "text-single";
2431
2432                    if (formType.equals("result") || fieldType.equals("fixed")) {
2433                        viewType = TYPE_RESULT_FIELD;
2434                    } else if (formType.equals("form")) {
2435                        if (fieldType.equals("boolean")) {
2436                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2437                                viewType = TYPE_BUTTON_GRID_FIELD;
2438                            } else {
2439                                viewType = TYPE_CHECKBOX_FIELD;
2440                            }
2441                        } else if (fieldType.equals("list-single")) {
2442                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2443                            if (Option.forField(el).size() > 9) {
2444                                viewType = TYPE_SEARCH_LIST_FIELD;
2445                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2446                                viewType = TYPE_BUTTON_GRID_FIELD;
2447                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2448                                viewType = TYPE_RADIO_EDIT_FIELD;
2449                            } else {
2450                                viewType = TYPE_SPINNER_FIELD;
2451                            }
2452                        } else {
2453                            viewType = TYPE_TEXT_FIELD;
2454                        }
2455                    }
2456
2457                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2458                }
2459
2460                return null;
2461            }
2462
2463            protected Item mkItem(Element el, int pos) {
2464                int viewType = -1;
2465
2466                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2467                    if (el.getName().equals("note")) {
2468                        viewType = TYPE_NOTE;
2469                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2470                        viewType = TYPE_WEB;
2471                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2472                        viewType = TYPE_NOTE;
2473                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2474                        Field field = mkField(el);
2475                        if (field != null) {
2476                            items.put(pos, field);
2477                            return field;
2478                        }
2479                    }
2480                } else if (response != null) {
2481                    viewType = TYPE_ERROR;
2482                }
2483
2484                Item item = new Item(el, viewType);
2485                items.put(pos, item);
2486                return item;
2487            }
2488
2489            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2490                protected Context ctx;
2491
2492                public ActionsAdapter(Context ctx) {
2493                    super(ctx, R.layout.simple_list_item);
2494                    this.ctx = ctx;
2495                }
2496
2497                @Override
2498                public View getView(int position, View convertView, ViewGroup parent) {
2499                    View v = super.getView(position, convertView, parent);
2500                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2501                    tv.setGravity(Gravity.CENTER);
2502                    tv.setText(getItem(position).second);
2503                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2504                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2505                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2506                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2507                    return v;
2508                }
2509
2510                public int getPosition(String s) {
2511                    for(int i = 0; i < getCount(); i++) {
2512                        if (getItem(i).first.equals(s)) return i;
2513                    }
2514                    return -1;
2515                }
2516
2517                public int countExceptCancel() {
2518                    int count = 0;
2519                    for(int i = 0; i < getCount(); i++) {
2520                        if (!getItem(i).first.equals("cancel")) count++;
2521                    }
2522                    return count;
2523                }
2524
2525                public void clearExceptCancel() {
2526                    Pair<String,String> cancelItem = null;
2527                    for(int i = 0; i < getCount(); i++) {
2528                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2529                    }
2530                    clear();
2531                    if (cancelItem != null) add(cancelItem);
2532                }
2533            }
2534
2535            final int TYPE_ERROR = 1;
2536            final int TYPE_NOTE = 2;
2537            final int TYPE_WEB = 3;
2538            final int TYPE_RESULT_FIELD = 4;
2539            final int TYPE_TEXT_FIELD = 5;
2540            final int TYPE_CHECKBOX_FIELD = 6;
2541            final int TYPE_SPINNER_FIELD = 7;
2542            final int TYPE_RADIO_EDIT_FIELD = 8;
2543            final int TYPE_RESULT_CELL = 9;
2544            final int TYPE_PROGRESSBAR = 10;
2545            final int TYPE_SEARCH_LIST_FIELD = 11;
2546            final int TYPE_ITEM_CARD = 12;
2547            final int TYPE_BUTTON_GRID_FIELD = 13;
2548
2549            protected boolean loading = false;
2550            protected Timer loadingTimer = new Timer();
2551            protected String mTitle;
2552            protected String mNode;
2553            protected CommandPageBinding mBinding = null;
2554            protected IqPacket response = null;
2555            protected Element responseElement = null;
2556            protected List<Field> reported = null;
2557            protected SparseArray<Item> items = new SparseArray<>();
2558            protected XmppConnectionService xmppConnectionService;
2559            protected ActionsAdapter actionsAdapter;
2560            protected GridLayoutManager layoutManager;
2561            protected WebView actionToWebview = null;
2562            protected int fillableFieldCount = 0;
2563            protected IqPacket pendingResponsePacket = null;
2564
2565            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2566                loading();
2567                mTitle = title;
2568                mNode = node;
2569                this.xmppConnectionService = xmppConnectionService;
2570                if (mPager != null) setupLayoutManager();
2571                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2572                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2573                    @Override
2574                    public void onChanged() {
2575                        if (mBinding == null) return;
2576
2577                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2578                    }
2579
2580                    @Override
2581                    public void onInvalidated() {}
2582                });
2583            }
2584
2585            public String getTitle() {
2586                return mTitle;
2587            }
2588
2589            public String getNode() {
2590                return mNode;
2591            }
2592
2593            public void updateWithResponse(final IqPacket iq) {
2594                if (getView() != null && getView().isAttachedToWindow()) {
2595                    getView().post(() -> updateWithResponseUiThread(iq));
2596                } else {
2597                    pendingResponsePacket = iq;
2598                }
2599            }
2600
2601            protected void updateWithResponseUiThread(final IqPacket iq) {
2602                this.loadingTimer.cancel();
2603                this.loadingTimer = new Timer();
2604                this.loading = false;
2605                this.responseElement = null;
2606                this.fillableFieldCount = 0;
2607                this.reported = null;
2608                this.response = iq;
2609                this.items.clear();
2610                this.actionsAdapter.clear();
2611                layoutManager.setSpanCount(1);
2612
2613                boolean actionsCleared = false;
2614                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2615                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2616                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2617                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2618                    }
2619
2620                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2621                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2622                    }
2623
2624                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2625                    if (actions != null) {
2626                        for (Element action : actions.getChildren()) {
2627                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2628                            if ("execute".equals(action.getName())) continue;
2629
2630                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2631                        }
2632                    }
2633
2634                    for (Element el : command.getChildren()) {
2635                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2636                            Data form = Data.parse(el);
2637                            String title = form.getTitle();
2638                            if (title != null) {
2639                                mTitle = title;
2640                                ConversationPagerAdapter.this.notifyDataSetChanged();
2641                            }
2642
2643                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2644                                this.responseElement = el;
2645                                setupReported(el.findChild("reported", "jabber:x:data"));
2646                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2647                            }
2648
2649                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2650                            if (actionList != null) {
2651                                actionsAdapter.clear();
2652
2653                                for (Option action : actionList.getOptions()) {
2654                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2655                                }
2656                            }
2657
2658                            String fillableFieldType = null;
2659                            String fillableFieldValue = null;
2660                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2661                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2662                                    fillableFieldType = field.getType();
2663                                    fillableFieldValue = field.getValue();
2664                                    fillableFieldCount++;
2665                                }
2666                            }
2667
2668                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2669                                actionsCleared = true;
2670                                actionsAdapter.clearExceptCancel();
2671                            }
2672                            break;
2673                        }
2674                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2675                            String url = el.findChildContent("url", "jabber:x:oob");
2676                            if (url != null) {
2677                                String scheme = Uri.parse(url).getScheme();
2678                                if (scheme.equals("http") || scheme.equals("https")) {
2679                                    this.responseElement = el;
2680                                    break;
2681                                }
2682                                if (scheme.equals("xmpp")) {
2683                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2684                                    intent.setAction(Intent.ACTION_VIEW);
2685                                    intent.setData(Uri.parse(url));
2686                                    getView().getContext().startActivity(intent);
2687                                    break;
2688                                }
2689                            }
2690                        }
2691                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2692                            this.responseElement = el;
2693                            break;
2694                        }
2695                    }
2696
2697                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2698                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2699                            if (xmppConnectionService.isOnboarding()) {
2700                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2701                                    xmppConnectionService.deleteAccount(getAccount());
2702                                } else {
2703                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2704                                        removeSession(this);
2705                                        return;
2706                                    } else {
2707                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2708                                        xmppConnectionService.deleteAccount(getAccount());
2709                                    }
2710                                }
2711                            }
2712                            xmppConnectionService.archiveConversation(Conversation.this);
2713                        }
2714
2715                        removeSession(this);
2716                        return;
2717                    }
2718
2719                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2720                        // No actions have been given, but we are not done?
2721                        // This is probably a spec violation, but we should do *something*
2722                        actionsAdapter.add(Pair.create("execute", "execute"));
2723                    }
2724
2725                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2726                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2727                            actionsAdapter.add(Pair.create("close", "close"));
2728                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2729                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2730                        }
2731                    }
2732                }
2733
2734                if (actionsAdapter.isEmpty()) {
2735                    actionsAdapter.add(Pair.create("close", "close"));
2736                }
2737
2738                actionsAdapter.sort((x, y) -> {
2739                    if (x.first.equals("cancel")) return -1;
2740                    if (y.first.equals("cancel")) return 1;
2741                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2742                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2743                    return 0;
2744                });
2745
2746                Data dataForm = null;
2747                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2748                if (mNode.equals("jabber:iq:register") &&
2749                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
2750                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2751
2752
2753                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2754                    execute();
2755                }
2756                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2757                notifyDataSetChanged();
2758            }
2759
2760            protected void setupReported(Element el) {
2761                if (el == null) {
2762                    reported = null;
2763                    return;
2764                }
2765
2766                reported = new ArrayList<>();
2767                for (Element fieldEl : el.getChildren()) {
2768                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2769                    reported.add(mkField(fieldEl));
2770                }
2771            }
2772
2773            @Override
2774            public int getItemCount() {
2775                if (loading) return 1;
2776                if (response == null) return 0;
2777                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2778                    int i = 0;
2779                    for (Element el : responseElement.getChildren()) {
2780                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2781                        if (el.getName().equals("title")) continue;
2782                        if (el.getName().equals("field")) {
2783                            String type = el.getAttribute("type");
2784                            if (type != null && type.equals("hidden")) continue;
2785                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2786                        }
2787
2788                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2789                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2790                                if (el.getName().equals("reported")) continue;
2791                                i += 1;
2792                            } else {
2793                                if (reported != null) i += reported.size();
2794                            }
2795                            continue;
2796                        }
2797
2798                        i++;
2799                    }
2800                    return i;
2801                }
2802                return 1;
2803            }
2804
2805            public Item getItem(int position) {
2806                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2807                if (items.get(position) != null) return items.get(position);
2808                if (response == null) return null;
2809
2810                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2811                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2812                        int i = 0;
2813                        for (Element el : responseElement.getChildren()) {
2814                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2815                            if (el.getName().equals("title")) continue;
2816                            if (el.getName().equals("field")) {
2817                                String type = el.getAttribute("type");
2818                                if (type != null && type.equals("hidden")) continue;
2819                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2820                            }
2821
2822                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2823                                Cell cell = null;
2824
2825                                if (reported != null) {
2826                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2827                                        if (el.getName().equals("reported")) continue;
2828                                        if (i == position) {
2829                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2830                                            return items.get(position);
2831                                        }
2832                                    } else {
2833                                        if (reported.size() > position - i) {
2834                                            Field reportedField = reported.get(position - i);
2835                                            Element itemField = null;
2836                                            if (el.getName().equals("item")) {
2837                                                for (Element subel : el.getChildren()) {
2838                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2839                                                       itemField = subel;
2840                                                       break;
2841                                                    }
2842                                                }
2843                                            }
2844                                            cell = new Cell(reportedField, itemField);
2845                                        } else {
2846                                            i += reported.size();
2847                                            continue;
2848                                        }
2849                                    }
2850                                }
2851
2852                                if (cell != null) {
2853                                    items.put(position, cell);
2854                                    return cell;
2855                                }
2856                            }
2857
2858                            if (i < position) {
2859                                i++;
2860                                continue;
2861                            }
2862
2863                            return mkItem(el, position);
2864                        }
2865                    }
2866                }
2867
2868                return mkItem(responseElement == null ? response : responseElement, position);
2869            }
2870
2871            @Override
2872            public int getItemViewType(int position) {
2873                return getItem(position).viewType;
2874            }
2875
2876            @Override
2877            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2878                switch(viewType) {
2879                    case TYPE_ERROR: {
2880                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2881                        return new ErrorViewHolder(binding);
2882                    }
2883                    case TYPE_NOTE: {
2884                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2885                        return new NoteViewHolder(binding);
2886                    }
2887                    case TYPE_WEB: {
2888                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2889                        return new WebViewHolder(binding);
2890                    }
2891                    case TYPE_RESULT_FIELD: {
2892                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2893                        return new ResultFieldViewHolder(binding);
2894                    }
2895                    case TYPE_RESULT_CELL: {
2896                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2897                        return new ResultCellViewHolder(binding);
2898                    }
2899                    case TYPE_ITEM_CARD: {
2900                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2901                        return new ItemCardViewHolder(binding);
2902                    }
2903                    case TYPE_CHECKBOX_FIELD: {
2904                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2905                        return new CheckboxFieldViewHolder(binding);
2906                    }
2907                    case TYPE_SEARCH_LIST_FIELD: {
2908                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2909                        return new SearchListFieldViewHolder(binding);
2910                    }
2911                    case TYPE_RADIO_EDIT_FIELD: {
2912                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2913                        return new RadioEditFieldViewHolder(binding);
2914                    }
2915                    case TYPE_SPINNER_FIELD: {
2916                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2917                        return new SpinnerFieldViewHolder(binding);
2918                    }
2919                    case TYPE_BUTTON_GRID_FIELD: {
2920                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2921                        return new ButtonGridFieldViewHolder(binding);
2922                    }
2923                    case TYPE_TEXT_FIELD: {
2924                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2925                        return new TextFieldViewHolder(binding);
2926                    }
2927                    case TYPE_PROGRESSBAR: {
2928                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2929                        return new ProgressBarViewHolder(binding);
2930                    }
2931                    default:
2932                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2933                }
2934            }
2935
2936            @Override
2937            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2938                viewHolder.bind(getItem(position));
2939            }
2940
2941            public View getView() {
2942                if (mBinding == null) return null;
2943                return mBinding.getRoot();
2944            }
2945
2946            public boolean validate() {
2947                int count = getItemCount();
2948                boolean isValid = true;
2949                for (int i = 0; i < count; i++) {
2950                    boolean oneIsValid = getItem(i).validate();
2951                    isValid = isValid && oneIsValid;
2952                }
2953                notifyDataSetChanged();
2954                return isValid;
2955            }
2956
2957            public boolean execute() {
2958                return execute("execute");
2959            }
2960
2961            public boolean execute(int actionPosition) {
2962                return execute(actionsAdapter.getItem(actionPosition).first);
2963            }
2964
2965            public boolean execute(String action) {
2966                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2967
2968                if (response == null) return true;
2969                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2970                if (command == null) return true;
2971                String status = command.getAttribute("status");
2972                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2973
2974                if (actionToWebview != null && !action.equals("cancel")) {
2975                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2976                    return false;
2977                }
2978
2979                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2980                packet.setTo(response.getFrom());
2981                final Element c = packet.addChild("command", Namespace.COMMANDS);
2982                c.setAttribute("node", mNode);
2983                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2984
2985                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2986                if (!action.equals("cancel") &&
2987                    !action.equals("prev") &&
2988                    responseElement != null &&
2989                    responseElement.getName().equals("x") &&
2990                    responseElement.getNamespace().equals("jabber:x:data") &&
2991                    formType != null && formType.equals("form")) {
2992
2993                    Data form = Data.parse(responseElement);
2994                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2995                    if (actionList != null) {
2996                        actionList.setValue(action);
2997                        c.setAttribute("action", "execute");
2998                    }
2999
3000                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3001                        if (form.getValue("gateway-jid") == null) {
3002                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3003                        } else {
3004                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3005                        }
3006                    }
3007
3008                    responseElement.setAttribute("type", "submit");
3009                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3010                    if (rsm != null) {
3011                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3012                        max.setContent("1000");
3013                        rsm.addChild(max);
3014                    }
3015
3016                    c.addChild(responseElement);
3017                }
3018
3019                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3020
3021                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3022                    updateWithResponse(iq);
3023                });
3024
3025                loading();
3026                return false;
3027            }
3028
3029            public void refresh() {
3030                notifyDataSetChanged();
3031            }
3032
3033            protected void loading() {
3034                View v = getView();
3035                loadingTimer.schedule(new TimerTask() {
3036                    @Override
3037                    public void run() {
3038                        View v2 = getView();
3039                        loading = true;
3040
3041                        if (v == null && v2 == null) return;
3042                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3043                    }
3044                }, 500);
3045            }
3046
3047            protected GridLayoutManager setupLayoutManager() {
3048                int spanCount = 1;
3049
3050                Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3051                if (reported != null) {
3052                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3053                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3054                    float tableHeaderWidth = reported.stream().reduce(
3055                        0f,
3056                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3057                        (a, b) -> a + b
3058                    );
3059
3060                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3061                }
3062
3063                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3064                    items.clear();
3065                    notifyDataSetChanged();
3066                }
3067
3068                layoutManager = new GridLayoutManager(ctx, spanCount);
3069                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3070                    @Override
3071                    public int getSpanSize(int position) {
3072                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3073                        return 1;
3074                    }
3075                });
3076                return layoutManager;
3077            }
3078
3079            protected void setBinding(CommandPageBinding b) {
3080                mBinding = b;
3081                // https://stackoverflow.com/a/32350474/8611
3082                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3083                    @Override
3084                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3085                        if(rv.getChildCount() > 0) {
3086                            int[] location = new int[2];
3087                            rv.getLocationOnScreen(location);
3088                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3089                            if (childView instanceof ViewGroup) {
3090                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3091                            }
3092                            int action = e.getAction();
3093                            switch (action) {
3094                                case MotionEvent.ACTION_DOWN:
3095                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3096                                        rv.requestDisallowInterceptTouchEvent(true);
3097                                    }
3098                                case MotionEvent.ACTION_UP:
3099                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3100                                        rv.requestDisallowInterceptTouchEvent(true);
3101                                    }
3102                            }
3103                        }
3104
3105                        return false;
3106                    }
3107
3108                    @Override
3109                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3110
3111                    @Override
3112                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3113                });
3114                mBinding.form.setLayoutManager(setupLayoutManager());
3115                mBinding.form.setAdapter(this);
3116                mBinding.actions.setAdapter(actionsAdapter);
3117                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3118                    if (execute(pos)) {
3119                        removeSession(CommandSession.this);
3120                    }
3121                });
3122
3123                actionsAdapter.notifyDataSetChanged();
3124
3125                if (pendingResponsePacket != null) {
3126                    final IqPacket pending = pendingResponsePacket;
3127                    pendingResponsePacket = null;
3128                    updateWithResponseUiThread(pending);
3129                }
3130            }
3131
3132            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3133                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3134                setBinding(binding);
3135                return binding.getRoot();
3136            }
3137
3138            // https://stackoverflow.com/a/36037991/8611
3139            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3140                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3141                    View child = viewGroup.getChildAt(i);
3142                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3143                        View foundView = findViewAt((ViewGroup) child, x, y);
3144                        if (foundView != null && foundView.isShown()) {
3145                            return foundView;
3146                        }
3147                    } else {
3148                        int[] location = new int[2];
3149                        child.getLocationOnScreen(location);
3150                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3151                        if (rect.contains((int)x, (int)y)) {
3152                            return child;
3153                        }
3154                    }
3155                }
3156
3157                return null;
3158            }
3159        }
3160    }
3161}