Conversation.java

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