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