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.AnimatedImageDrawable;
  10import android.graphics.drawable.BitmapDrawable;
  11import android.graphics.drawable.Drawable;
  12import android.graphics.Bitmap;
  13import android.graphics.Canvas;
  14import android.graphics.Rect;
  15import android.os.Build;
  16import android.net.Uri;
  17import android.telephony.PhoneNumberUtils;
  18import android.text.Editable;
  19import android.text.InputType;
  20import android.text.SpannableStringBuilder;
  21import android.text.Spanned;
  22import android.text.StaticLayout;
  23import android.text.TextPaint;
  24import android.text.TextUtils;
  25import android.text.TextWatcher;
  26import android.view.LayoutInflater;
  27import android.view.MotionEvent;
  28import android.view.Gravity;
  29import android.view.View;
  30import android.view.ViewGroup;
  31import android.widget.AbsListView;
  32import android.widget.ArrayAdapter;
  33import android.widget.AdapterView;
  34import android.widget.Button;
  35import android.widget.CompoundButton;
  36import android.widget.GridLayout;
  37import android.widget.ListView;
  38import android.widget.TextView;
  39import android.widget.Toast;
  40import android.widget.Spinner;
  41import android.webkit.JavascriptInterface;
  42import android.webkit.WebMessage;
  43import android.webkit.WebView;
  44import android.webkit.WebViewClient;
  45import android.webkit.WebChromeClient;
  46import android.util.DisplayMetrics;
  47import android.util.LruCache;
  48import android.util.Pair;
  49import android.util.SparseArray;
  50
  51import androidx.annotation.NonNull;
  52import androidx.annotation.Nullable;
  53import androidx.appcompat.app.AlertDialog;
  54import androidx.appcompat.app.AlertDialog.Builder;
  55import androidx.core.content.ContextCompat;
  56import androidx.core.util.Consumer;
  57import androidx.databinding.DataBindingUtil;
  58import androidx.databinding.ViewDataBinding;
  59import androidx.viewpager.widget.PagerAdapter;
  60import androidx.recyclerview.widget.RecyclerView;
  61import androidx.recyclerview.widget.GridLayoutManager;
  62import androidx.viewpager.widget.ViewPager;
  63
  64import com.caverock.androidsvg.SVG;
  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 {
 857            return this.getContact().getDisplayName();
 858        }
 859    }
 860
 861    public String getAccountUuid() {
 862        return this.accountUuid;
 863    }
 864
 865    public Account getAccount() {
 866        return this.account;
 867    }
 868
 869    public void setAccount(final Account account) {
 870        this.account = account;
 871    }
 872
 873    public Contact getContact() {
 874        return this.account.getRoster().getContact(this.contactJid);
 875    }
 876
 877    @Override
 878    public Jid getJid() {
 879        return this.contactJid;
 880    }
 881
 882    public int getStatus() {
 883        return this.status;
 884    }
 885
 886    public void setStatus(int status) {
 887        this.status = status;
 888    }
 889
 890    public long getCreated() {
 891        return this.created;
 892    }
 893
 894    public ContentValues getContentValues() {
 895        ContentValues values = new ContentValues();
 896        values.put(UUID, uuid);
 897        values.put(NAME, name);
 898        values.put(CONTACT, contactUuid);
 899        values.put(ACCOUNT, accountUuid);
 900        values.put(CONTACTJID, contactJid.toString());
 901        values.put(CREATED, created);
 902        values.put(STATUS, status);
 903        values.put(MODE, mode);
 904        synchronized (this.attributes) {
 905            values.put(ATTRIBUTES, attributes.toString());
 906        }
 907        return values;
 908    }
 909
 910    public int getMode() {
 911        return this.mode;
 912    }
 913
 914    public void setMode(int mode) {
 915        this.mode = mode;
 916    }
 917
 918    /**
 919     * short for is Private and Non-anonymous
 920     */
 921    public boolean isSingleOrPrivateAndNonAnonymous() {
 922        return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
 923    }
 924
 925    public boolean isPrivateAndNonAnonymous() {
 926        return getMucOptions().isPrivateAndNonAnonymous();
 927    }
 928
 929    public synchronized MucOptions getMucOptions() {
 930        if (this.mucOptions == null) {
 931            this.mucOptions = new MucOptions(this);
 932        }
 933        return this.mucOptions;
 934    }
 935
 936    public void resetMucOptions() {
 937        this.mucOptions = null;
 938    }
 939
 940    public void setContactJid(final Jid jid) {
 941        this.contactJid = jid;
 942    }
 943
 944    public Jid getNextCounterpart() {
 945        return this.nextCounterpart;
 946    }
 947
 948    public void setNextCounterpart(Jid jid) {
 949        this.nextCounterpart = jid;
 950    }
 951
 952    public int getNextEncryption() {
 953        if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
 954            return Message.ENCRYPTION_NONE;
 955        }
 956        if (OmemoSetting.isAlways()) {
 957            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
 958        }
 959        final int defaultEncryption;
 960        if (suitableForOmemoByDefault(this)) {
 961            defaultEncryption = OmemoSetting.getEncryption();
 962        } else {
 963            defaultEncryption = Message.ENCRYPTION_NONE;
 964        }
 965        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
 966        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
 967            return defaultEncryption;
 968        } else {
 969            return encryption;
 970        }
 971    }
 972
 973    public boolean setNextEncryption(int encryption) {
 974        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
 975    }
 976
 977    public String getNextMessage() {
 978        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 979        return nextMessage == null ? "" : nextMessage;
 980    }
 981
 982    public @Nullable
 983    Draft getDraft() {
 984        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
 985        if (timestamp > getLatestMessage().getTimeSent()) {
 986            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 987            if (!TextUtils.isEmpty(message) && timestamp != 0) {
 988                return new Draft(message, timestamp);
 989            }
 990        }
 991        return null;
 992    }
 993
 994    public boolean setNextMessage(final String input) {
 995        final String message = input == null || input.trim().isEmpty() ? null : input;
 996        boolean changed = !getNextMessage().equals(message);
 997        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
 998        if (changed) {
 999            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
1000        }
1001        return changed;
1002    }
1003
1004    public Bookmark getBookmark() {
1005        return this.account.getBookmark(this.contactJid);
1006    }
1007
1008    public Message findDuplicateMessage(Message message) {
1009        synchronized (this.messages) {
1010            for (int i = this.messages.size() - 1; i >= 0; --i) {
1011                if (this.messages.get(i).similar(message)) {
1012                    return this.messages.get(i);
1013                }
1014            }
1015        }
1016        return null;
1017    }
1018
1019    public boolean hasDuplicateMessage(Message message) {
1020        return findDuplicateMessage(message) != null;
1021    }
1022
1023    public Message findSentMessageWithBody(String body) {
1024        synchronized (this.messages) {
1025            for (int i = this.messages.size() - 1; i >= 0; --i) {
1026                Message message = this.messages.get(i);
1027                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
1028                    String otherBody;
1029                    if (message.hasFileOnRemoteHost()) {
1030                        otherBody = message.getFileParams().url;
1031                    } else {
1032                        otherBody = message.body;
1033                    }
1034                    if (otherBody != null && otherBody.equals(body)) {
1035                        return message;
1036                    }
1037                }
1038            }
1039            return null;
1040        }
1041    }
1042
1043    public Message findRtpSession(final String sessionId, final int s) {
1044        synchronized (this.messages) {
1045            for (int i = this.messages.size() - 1; i >= 0; --i) {
1046                final Message message = this.messages.get(i);
1047                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
1048                    return message;
1049                }
1050            }
1051        }
1052        return null;
1053    }
1054
1055    public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
1056        if (serverMsgId == null || remoteMsgId == null) {
1057            return false;
1058        }
1059        synchronized (this.messages) {
1060            for (Message message : this.messages) {
1061                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
1062                    return true;
1063                }
1064            }
1065        }
1066        return false;
1067    }
1068
1069    public MamReference getLastMessageTransmitted() {
1070        final MamReference lastClear = getLastClearHistory();
1071        MamReference lastReceived = new MamReference(0);
1072        synchronized (this.messages) {
1073            for (int i = this.messages.size() - 1; i >= 0; --i) {
1074                final Message message = this.messages.get(i);
1075                if (message.isPrivateMessage()) {
1076                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
1077                }
1078                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
1079                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
1080                    break;
1081                }
1082            }
1083        }
1084        return MamReference.max(lastClear, lastReceived);
1085    }
1086
1087    public void setMutedTill(long value) {
1088        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
1089    }
1090
1091    public boolean isMuted() {
1092        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
1093    }
1094
1095    public boolean alwaysNotify() {
1096        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
1097    }
1098
1099    public boolean notifyReplies() {
1100        return alwaysNotify() || getBooleanAttribute(ATTRIBUTE_NOTIFY_REPLIES, false);
1101    }
1102
1103    public boolean setAttribute(String key, boolean value) {
1104        return setAttribute(key, String.valueOf(value));
1105    }
1106
1107    private boolean setAttribute(String key, long value) {
1108        return setAttribute(key, Long.toString(value));
1109    }
1110
1111    private boolean setAttribute(String key, int value) {
1112        return setAttribute(key, String.valueOf(value));
1113    }
1114
1115    public boolean setAttribute(String key, String value) {
1116        synchronized (this.attributes) {
1117            try {
1118                if (value == null) {
1119                    if (this.attributes.has(key)) {
1120                        this.attributes.remove(key);
1121                        return true;
1122                    } else {
1123                        return false;
1124                    }
1125                } else {
1126                    final String prev = this.attributes.optString(key, null);
1127                    this.attributes.put(key, value);
1128                    return !value.equals(prev);
1129                }
1130            } catch (JSONException e) {
1131                throw new AssertionError(e);
1132            }
1133        }
1134    }
1135
1136    public boolean setAttribute(String key, List<Jid> jids) {
1137        JSONArray array = new JSONArray();
1138        for (Jid jid : jids) {
1139            array.put(jid.asBareJid().toString());
1140        }
1141        synchronized (this.attributes) {
1142            try {
1143                this.attributes.put(key, array);
1144                return true;
1145            } catch (JSONException e) {
1146                return false;
1147            }
1148        }
1149    }
1150
1151    public String getAttribute(String key) {
1152        synchronized (this.attributes) {
1153            return this.attributes.optString(key, null);
1154        }
1155    }
1156
1157    private List<Jid> getJidListAttribute(String key) {
1158        ArrayList<Jid> list = new ArrayList<>();
1159        synchronized (this.attributes) {
1160            try {
1161                JSONArray array = this.attributes.getJSONArray(key);
1162                for (int i = 0; i < array.length(); ++i) {
1163                    try {
1164                        list.add(Jid.of(array.getString(i)));
1165                    } catch (IllegalArgumentException e) {
1166                        //ignored
1167                    }
1168                }
1169            } catch (JSONException e) {
1170                //ignored
1171            }
1172        }
1173        return list;
1174    }
1175
1176    private int getIntAttribute(String key, int defaultValue) {
1177        String value = this.getAttribute(key);
1178        if (value == null) {
1179            return defaultValue;
1180        } else {
1181            try {
1182                return Integer.parseInt(value);
1183            } catch (NumberFormatException e) {
1184                return defaultValue;
1185            }
1186        }
1187    }
1188
1189    public long getLongAttribute(String key, long defaultValue) {
1190        String value = this.getAttribute(key);
1191        if (value == null) {
1192            return defaultValue;
1193        } else {
1194            try {
1195                return Long.parseLong(value);
1196            } catch (NumberFormatException e) {
1197                return defaultValue;
1198            }
1199        }
1200    }
1201
1202    public boolean getBooleanAttribute(String key, boolean defaultValue) {
1203        String value = this.getAttribute(key);
1204        if (value == null) {
1205            return defaultValue;
1206        } else {
1207            return Boolean.parseBoolean(value);
1208        }
1209    }
1210
1211    public void add(Message message) {
1212        synchronized (this.messages) {
1213            this.messages.add(message);
1214        }
1215    }
1216
1217    public void prepend(int offset, Message message) {
1218        synchronized (this.messages) {
1219            this.messages.add(Math.min(offset, this.messages.size()), message);
1220        }
1221    }
1222
1223    public void addAll(int index, List<Message> messages) {
1224        synchronized (this.messages) {
1225            this.messages.addAll(index, messages);
1226        }
1227        account.getPgpDecryptionService().decrypt(messages);
1228    }
1229
1230    public void expireOldMessages(long timestamp) {
1231        synchronized (this.messages) {
1232            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1233                if (iterator.next().getTimeSent() < timestamp) {
1234                    iterator.remove();
1235                }
1236            }
1237            untieMessages();
1238        }
1239    }
1240
1241    public void sort() {
1242        synchronized (this.messages) {
1243            Collections.sort(this.messages, (left, right) -> {
1244                if (left.getTimeSent() < right.getTimeSent()) {
1245                    return -1;
1246                } else if (left.getTimeSent() > right.getTimeSent()) {
1247                    return 1;
1248                } else {
1249                    return 0;
1250                }
1251            });
1252            untieMessages();
1253        }
1254    }
1255
1256    private void untieMessages() {
1257        for (Message message : this.messages) {
1258            message.untie();
1259        }
1260    }
1261
1262    public int unreadCount() {
1263        synchronized (this.messages) {
1264            int count = 0;
1265            for(final Message message : Lists.reverse(this.messages)) {
1266                if (message.isRead()) {
1267                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1268                        continue;
1269                    }
1270                    return count;
1271                }
1272                ++count;
1273            }
1274            return count;
1275        }
1276    }
1277
1278    public int receivedMessagesCount() {
1279        int count = 0;
1280        synchronized (this.messages) {
1281            for (Message message : messages) {
1282                if (message.getStatus() == Message.STATUS_RECEIVED) {
1283                    ++count;
1284                }
1285            }
1286        }
1287        return count;
1288    }
1289
1290    public int sentMessagesCount() {
1291        int count = 0;
1292        synchronized (this.messages) {
1293            for (Message message : messages) {
1294                if (message.getStatus() != Message.STATUS_RECEIVED) {
1295                    ++count;
1296                }
1297            }
1298        }
1299        return count;
1300    }
1301
1302    public boolean canInferPresence() {
1303        final Contact contact = getContact();
1304        if (contact != null && contact.canInferPresence()) return true;
1305        return sentMessagesCount() > 0;
1306    }
1307
1308    public boolean isWithStranger() {
1309        final Contact contact = getContact();
1310        return mode == MODE_SINGLE
1311                && !contact.isOwnServer()
1312                && !contact.showInContactList()
1313                && !contact.isSelf()
1314                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1315                && sentMessagesCount() == 0;
1316    }
1317
1318    public int getReceivedMessagesCountSinceUuid(String uuid) {
1319        if (uuid == null) {
1320            return 0;
1321        }
1322        int count = 0;
1323        synchronized (this.messages) {
1324            for (int i = messages.size() - 1; i >= 0; i--) {
1325                final Message message = messages.get(i);
1326                if (uuid.equals(message.getUuid())) {
1327                    return count;
1328                }
1329                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1330                    ++count;
1331                }
1332            }
1333        }
1334        return 0;
1335    }
1336
1337    @Override
1338    public int getAvatarBackgroundColor() {
1339        return UIHelper.getColorForName(getName().toString());
1340    }
1341
1342    @Override
1343    public String getAvatarName() {
1344        return getName().toString();
1345    }
1346
1347    public void setCurrentTab(int tab) {
1348        mCurrentTab = tab;
1349    }
1350
1351    public int getCurrentTab() {
1352        if (mCurrentTab >= 0) return mCurrentTab;
1353
1354        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1355            return 0;
1356        }
1357
1358        return 1;
1359    }
1360
1361    public void refreshSessions() {
1362        pagerAdapter.refreshSessions();
1363    }
1364
1365    public void startWebxdc(WebxdcPage page) {
1366        pagerAdapter.startWebxdc(page);
1367    }
1368
1369    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1370        pagerAdapter.startCommand(command, xmppConnectionService);
1371    }
1372
1373    public boolean switchToSession(final String node) {
1374        return pagerAdapter.switchToSession(node);
1375    }
1376
1377    public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1378        pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1379    }
1380
1381    public void showViewPager() {
1382        pagerAdapter.show();
1383    }
1384
1385    public void hideViewPager() {
1386        pagerAdapter.hide();
1387    }
1388
1389    public interface OnMessageFound {
1390        void onMessageFound(final Message message);
1391    }
1392
1393    public static class Draft {
1394        private final String message;
1395        private final long timestamp;
1396
1397        private Draft(String message, long timestamp) {
1398            this.message = message;
1399            this.timestamp = timestamp;
1400        }
1401
1402        public long getTimestamp() {
1403            return timestamp;
1404        }
1405
1406        public String getMessage() {
1407            return message;
1408        }
1409    }
1410
1411    public class ConversationPagerAdapter extends PagerAdapter {
1412        protected ViewPager mPager = null;
1413        protected TabLayout mTabs = null;
1414        ArrayList<ConversationPage> sessions = null;
1415        protected View page1 = null;
1416        protected View page2 = null;
1417        protected boolean mOnboarding = false;
1418
1419        public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1420            mPager = pager;
1421            mTabs = tabs;
1422            mOnboarding = onboarding;
1423
1424            if (oldConversation != null) {
1425                oldConversation.pagerAdapter.mPager = null;
1426                oldConversation.pagerAdapter.mTabs = null;
1427            }
1428
1429            if (mPager == null) {
1430                page1 = null;
1431                page2 = null;
1432                return;
1433            }
1434            if (sessions != null) show();
1435
1436            if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1437            if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1438            if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1439                page1 = null;
1440                page2 = null;
1441            }
1442            if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1443            if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1444            if (page1 == null || page2 == null) {
1445                throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1446            }
1447            pager.removeView(page1);
1448            pager.removeView(page2);
1449            pager.setAdapter(this);
1450            tabs.setupWithViewPager(mPager);
1451            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1452
1453            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1454                public void onPageScrollStateChanged(int state) { }
1455                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1456
1457                public void onPageSelected(int position) {
1458                    setCurrentTab(position);
1459                }
1460            });
1461        }
1462
1463        public void show() {
1464            if (sessions == null) {
1465                sessions = new ArrayList<>();
1466                notifyDataSetChanged();
1467            }
1468            if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1469        }
1470
1471        public void hide() {
1472            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1473            if (mPager != null) mPager.setCurrentItem(0);
1474            if (mTabs != null) mTabs.setVisibility(View.GONE);
1475            sessions = null;
1476            notifyDataSetChanged();
1477        }
1478
1479        public void refreshSessions() {
1480            if (sessions == null) return;
1481
1482            for (ConversationPage session : sessions) {
1483                session.refresh();
1484            }
1485        }
1486
1487        public void startWebxdc(WebxdcPage page) {
1488            show();
1489            sessions.add(page);
1490            notifyDataSetChanged();
1491            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1492        }
1493
1494        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1495            show();
1496            CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1497
1498            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1499            packet.setTo(command.getAttributeAsJid("jid"));
1500            final Element c = packet.addChild("command", Namespace.COMMANDS);
1501            c.setAttribute("node", command.getAttribute("node"));
1502            c.setAttribute("action", "execute");
1503
1504            final TimerTask task = new TimerTask() {
1505                @Override
1506                public void run() {
1507                    if (getAccount().getStatus() != Account.State.ONLINE) {
1508                        final TimerTask self = this;
1509                        new Timer().schedule(new TimerTask() {
1510                            @Override
1511                            public void run() {
1512                                self.run();
1513                            }
1514                        }, 1000);
1515                    } else {
1516                        xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1517                            session.updateWithResponse(iq);
1518                        }, 120L);
1519                    }
1520                }
1521            };
1522
1523            if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1524                new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1525                    if (signedData != null && signature != null) {
1526                        c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1527                        c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1528                    }
1529
1530                    task.run();
1531                }).checkLicense();
1532            } else {
1533                task.run();
1534            }
1535
1536            sessions.add(session);
1537            notifyDataSetChanged();
1538            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1539        }
1540
1541        public void removeSession(ConversationPage session) {
1542            sessions.remove(session);
1543            notifyDataSetChanged();
1544            if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1545        }
1546
1547        public boolean switchToSession(final String node) {
1548            if (sessions == null) return false;
1549
1550            int i = 0;
1551            for (ConversationPage session : sessions) {
1552                if (session.getNode().equals(node)) {
1553                    if (mPager != null) mPager.setCurrentItem(i + 2);
1554                    return true;
1555                }
1556                i++;
1557            }
1558
1559            return false;
1560        }
1561
1562        @NonNull
1563        @Override
1564        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1565            if (position == 0) {
1566                if (page1 != null && page1.getParent() != null) {
1567                    ((ViewGroup) page1.getParent()).removeView(page1);
1568                }
1569                container.addView(page1);
1570                return page1;
1571            }
1572            if (position == 1) {
1573                if (page2 != null && page2.getParent() != null) {
1574                    ((ViewGroup) page2.getParent()).removeView(page2);
1575                }
1576                container.addView(page2);
1577                return page2;
1578            }
1579
1580            ConversationPage session = sessions.get(position-2);
1581            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1582            if (v != null && v.getParent() != null) {
1583                ((ViewGroup) v.getParent()).removeView(v);
1584            }
1585            container.addView(v);
1586            return session;
1587        }
1588
1589        @Override
1590        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1591            if (position < 2) {
1592                container.removeView((View) o);
1593                return;
1594            }
1595
1596            container.removeView(((ConversationPage) o).getView());
1597        }
1598
1599        @Override
1600        public int getItemPosition(Object o) {
1601            if (mPager != null) {
1602                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1603                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1604            }
1605
1606            int pos = sessions == null ? -1 : sessions.indexOf(o);
1607            if (pos < 0) return PagerAdapter.POSITION_NONE;
1608            return pos + 2;
1609        }
1610
1611        @Override
1612        public int getCount() {
1613            if (sessions == null) return 1;
1614
1615            int count = 2 + sessions.size();
1616            if (mTabs == null) return count;
1617
1618            if (count > 2) {
1619                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1620            } else {
1621                mTabs.setTabMode(TabLayout.MODE_FIXED);
1622            }
1623            return count;
1624        }
1625
1626        @Override
1627        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1628            if (view == o) return true;
1629
1630            if (o instanceof ConversationPage) {
1631                return ((ConversationPage) o).getView() == view;
1632            }
1633
1634            return false;
1635        }
1636
1637        @Nullable
1638        @Override
1639        public CharSequence getPageTitle(int position) {
1640            switch (position) {
1641                case 0:
1642                    return "Conversation";
1643                case 1:
1644                    return "Commands";
1645                default:
1646                    ConversationPage session = sessions.get(position-2);
1647                    if (session == null) return super.getPageTitle(position);
1648                    return session.getTitle();
1649            }
1650        }
1651
1652        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1653            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1654                protected T binding;
1655
1656                public ViewHolder(T binding) {
1657                    super(binding.getRoot());
1658                    this.binding = binding;
1659                }
1660
1661                abstract public void bind(Item el);
1662
1663                protected void setTextOrHide(TextView v, Optional<String> s) {
1664                    if (s == null || !s.isPresent()) {
1665                        v.setVisibility(View.GONE);
1666                    } else {
1667                        v.setVisibility(View.VISIBLE);
1668                        v.setText(s.get());
1669                    }
1670                }
1671
1672                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1673                    int flags = 0;
1674                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1675                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1676
1677                    String type = field.getAttribute("type");
1678                    if (type != null) {
1679                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1680                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1681                        }
1682
1683                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1684
1685                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1686                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1687                        }
1688
1689                        if (type.equals("text-private")) {
1690                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1691                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1692                        }
1693                    }
1694
1695                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1696                    if (validate == null) return;
1697                    String datatype = validate.getAttribute("datatype");
1698                    if (datatype == null) return;
1699
1700                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1701                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1702                    }
1703
1704                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1705                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1706                    }
1707
1708                    if (datatype.equals("xs:date")) {
1709                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1710                    }
1711
1712                    if (datatype.equals("xs:dateTime")) {
1713                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1714                    }
1715
1716                    if (datatype.equals("xs:time")) {
1717                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1718                    }
1719
1720                    if (datatype.equals("xs:anyURI")) {
1721                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1722                    }
1723
1724                    if (datatype.equals("html:tel")) {
1725                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1726                    }
1727
1728                    if (datatype.equals("html:email")) {
1729                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1730                    }
1731                }
1732
1733                protected String formatValue(String datatype, String value, boolean compact) {
1734                    if ("xs:dateTime".equals(datatype)) {
1735                        ZonedDateTime zonedDateTime = null;
1736                        try {
1737                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1738                        } catch (final DateTimeParseException e) {
1739                            try {
1740                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1741                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
1742                            } catch (final DateTimeParseException e2) { }
1743                        }
1744                        if (zonedDateTime == null) return value;
1745                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1746                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1747                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
1748                    }
1749
1750                    if ("html:tel".equals(datatype) && !compact) {
1751                        return PhoneNumberUtils.formatNumber(value, value, null);
1752                    }
1753
1754                    return value;
1755                }
1756            }
1757
1758            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1759                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1760
1761                @Override
1762                public void bind(Item iq) {
1763                    binding.errorIcon.setVisibility(View.VISIBLE);
1764
1765                    Element error = iq.el.findChild("error");
1766                    if (error == null) return;
1767                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1768                    if (text == null || text.equals("")) {
1769                        text = error.getChildren().get(0).getName();
1770                    }
1771                    binding.message.setText(text);
1772                }
1773            }
1774
1775            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1776                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1777
1778                @Override
1779                public void bind(Item note) {
1780                    binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1781
1782                    String type = note.el.getAttribute("type");
1783                    if (type != null && type.equals("error")) {
1784                        binding.errorIcon.setVisibility(View.VISIBLE);
1785                    }
1786                }
1787            }
1788
1789            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1790                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1791
1792                @Override
1793                public void bind(Item item) {
1794                    Field field = (Field) item;
1795                    setTextOrHide(binding.label, field.getLabel());
1796                    setTextOrHide(binding.desc, field.getDesc());
1797
1798                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
1799                    if (media == null) {
1800                        binding.mediaImage.setVisibility(View.GONE);
1801                    } else {
1802                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1803                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1804                        for (Element uriEl : media.getChildren()) {
1805                            if (!"uri".equals(uriEl.getName())) continue;
1806                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1807                            String mimeType = uriEl.getAttribute("type");
1808                            String uriS = uriEl.getContent();
1809                            if (mimeType == null || uriS == null) continue;
1810                            Uri uri = Uri.parse(uriS);
1811                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1812                                final Drawable d = getDrawableForUrl(uri.toString());
1813                                if (d != null) {
1814                                    binding.mediaImage.setImageDrawable(d);
1815                                    binding.mediaImage.setVisibility(View.VISIBLE);
1816                                }
1817                            }
1818                        }
1819                    }
1820
1821                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1822                    String datatype = validate == null ? null : validate.getAttribute("datatype");
1823
1824                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1825                    for (Element el : field.el.getChildren()) {
1826                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1827                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1828                        }
1829                    }
1830                    binding.values.setAdapter(values);
1831                    Util.justifyListViewHeightBasedOnChildren(binding.values);
1832
1833                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1834                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1835                            new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
1836                        });
1837                    } else if ("xs:anyURI".equals(datatype)) {
1838                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1839                            new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1840                        });
1841                    } else if ("html:tel".equals(datatype)) {
1842                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1843                            try {
1844                                new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1845                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1846                        });
1847                    }
1848
1849                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1850                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1851                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1852                        }
1853                        return true;
1854                    });
1855                }
1856            }
1857
1858            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1859                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1860
1861                @Override
1862                public void bind(Item item) {
1863                    Cell cell = (Cell) item;
1864
1865                    if (cell.el == null) {
1866                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1867                        setTextOrHide(binding.text, cell.reported.getLabel());
1868                    } else {
1869                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1870                        String datatype = validate == null ? null : validate.getAttribute("datatype");
1871                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1872                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1873                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1874                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1875                        } else if ("xs:anyURI".equals(datatype)) {
1876                            text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1877                        } else if ("html:tel".equals(datatype)) {
1878                            try {
1879                                text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1880                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1881                        }
1882
1883                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1884                        binding.text.setText(text);
1885
1886                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1887                        method.setOnLinkLongClickListener((tv, url) -> {
1888                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1889                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1890                            return true;
1891                        });
1892                        binding.text.setMovementMethod(method);
1893                    }
1894                }
1895            }
1896
1897            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1898                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1899
1900                @Override
1901                public void bind(Item item) {
1902                    binding.fields.removeAllViews();
1903
1904                    for (Field field : reported) {
1905                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1906                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1907                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1908                        param.width = 0;
1909                        row.getRoot().setLayoutParams(param);
1910                        binding.fields.addView(row.getRoot());
1911                        for (Element el : item.el.getChildren()) {
1912                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1913                                for (String label : field.getLabel().asSet()) {
1914                                    el.setAttribute("label", label);
1915                                }
1916                                for (String desc : field.getDesc().asSet()) {
1917                                    el.setAttribute("desc", desc);
1918                                }
1919                                for (String type : field.getType().asSet()) {
1920                                    el.setAttribute("type", type);
1921                                }
1922                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1923                                if (validate != null) el.addChild(validate);
1924                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1925                            }
1926                        }
1927                    }
1928                }
1929            }
1930
1931            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1932                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1933                    super(binding);
1934                    binding.row.setOnClickListener((v) -> {
1935                        binding.checkbox.toggle();
1936                    });
1937                    binding.checkbox.setOnCheckedChangeListener(this);
1938                }
1939                protected Element mValue = null;
1940
1941                @Override
1942                public void bind(Item item) {
1943                    Field field = (Field) item;
1944                    binding.label.setText(field.getLabel().or(""));
1945                    setTextOrHide(binding.desc, field.getDesc());
1946                    mValue = field.getValue();
1947                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1948                }
1949
1950                @Override
1951                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1952                    if (mValue == null) return;
1953
1954                    mValue.setContent(isChecked ? "true" : "false");
1955                }
1956            }
1957
1958            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1959                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1960                    super(binding);
1961                    binding.search.addTextChangedListener(this);
1962                }
1963                protected Element mValue = null;
1964                List<Option> options = new ArrayList<>();
1965                protected ArrayAdapter<Option> adapter;
1966                protected boolean open;
1967
1968                @Override
1969                public void bind(Item item) {
1970                    Field field = (Field) item;
1971                    setTextOrHide(binding.label, field.getLabel());
1972                    setTextOrHide(binding.desc, field.getDesc());
1973
1974                    if (field.error != null) {
1975                        binding.desc.setVisibility(View.VISIBLE);
1976                        binding.desc.setText(field.error);
1977                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1978                    } else {
1979                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1980                    }
1981
1982                    mValue = field.getValue();
1983
1984                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1985                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1986                    setupInputType(field.el, binding.search, null);
1987
1988                    options = field.getOptions();
1989                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1990                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1991                        if (open) binding.search.setText(mValue.getContent());
1992                    });
1993                    search("");
1994                }
1995
1996                @Override
1997                public void afterTextChanged(Editable s) {
1998                    if (open) mValue.setContent(s.toString());
1999                    search(s.toString());
2000                }
2001
2002                @Override
2003                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2004
2005                @Override
2006                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2007
2008                protected void search(String s) {
2009                    List<Option> filteredOptions;
2010                    final String q = s.replaceAll("\\W", "").toLowerCase();
2011                    if (q == null || q.equals("")) {
2012                        filteredOptions = options;
2013                    } else {
2014                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2015                    }
2016                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2017                    binding.list.setAdapter(adapter);
2018
2019                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
2020                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2021                }
2022            }
2023
2024            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2025                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2026                    super(binding);
2027                    binding.open.addTextChangedListener(this);
2028                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2029                        @Override
2030                        public View getView(int position, View convertView, ViewGroup parent) {
2031                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2032                            v.setId(position);
2033                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2034                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2035                            return v;
2036                        }
2037                    };
2038                }
2039                protected Element mValue = null;
2040                protected ArrayAdapter<Option> options;
2041
2042                @Override
2043                public void bind(Item item) {
2044                    Field field = (Field) item;
2045                    setTextOrHide(binding.label, field.getLabel());
2046                    setTextOrHide(binding.desc, field.getDesc());
2047
2048                    if (field.error != null) {
2049                        binding.desc.setVisibility(View.VISIBLE);
2050                        binding.desc.setText(field.error);
2051                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2052                    } else {
2053                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2054                    }
2055
2056                    mValue = field.getValue();
2057
2058                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2059                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2060                    binding.open.setText(mValue.getContent());
2061                    setupInputType(field.el, binding.open, null);
2062
2063                    options.clear();
2064                    List<Option> theOptions = field.getOptions();
2065                    options.addAll(theOptions);
2066
2067                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2068                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2069                    float maxColumnWidth = theOptions.stream().map((x) ->
2070                        StaticLayout.getDesiredWidth(x.toString(), paint)
2071                    ).max(Float::compare).orElse(new Float(0.0));
2072                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2073                        binding.radios.setNumColumns(theOptions.size());
2074                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2075                        binding.radios.setNumColumns(theOptions.size() / 2);
2076                    } else {
2077                        binding.radios.setNumColumns(1);
2078                    }
2079                    binding.radios.setAdapter(options);
2080                }
2081
2082                @Override
2083                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2084                    if (mValue == null) return;
2085
2086                    if (isChecked) {
2087                        mValue.setContent(options.getItem(radio.getId()).getValue());
2088                        binding.open.setText(mValue.getContent());
2089                    }
2090                    options.notifyDataSetChanged();
2091                }
2092
2093                @Override
2094                public void afterTextChanged(Editable s) {
2095                    if (mValue == null) return;
2096
2097                    mValue.setContent(s.toString());
2098                    options.notifyDataSetChanged();
2099                }
2100
2101                @Override
2102                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2103
2104                @Override
2105                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2106            }
2107
2108            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2109                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2110                    super(binding);
2111                    binding.spinner.setOnItemSelectedListener(this);
2112                }
2113                protected Element mValue = null;
2114
2115                @Override
2116                public void bind(Item item) {
2117                    Field field = (Field) item;
2118                    setTextOrHide(binding.label, field.getLabel());
2119                    binding.spinner.setPrompt(field.getLabel().or(""));
2120                    setTextOrHide(binding.desc, field.getDesc());
2121
2122                    mValue = field.getValue();
2123
2124                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2125                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2126                    options.addAll(field.getOptions());
2127
2128                    binding.spinner.setAdapter(options);
2129                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2130                }
2131
2132                @Override
2133                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2134                    Option o = (Option) parent.getItemAtPosition(pos);
2135                    if (mValue == null) return;
2136
2137                    mValue.setContent(o == null ? "" : o.getValue());
2138                }
2139
2140                @Override
2141                public void onNothingSelected(AdapterView<?> parent) {
2142                    mValue.setContent("");
2143                }
2144            }
2145
2146            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2147                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2148                    super(binding);
2149                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2150                        protected int height = 0;
2151
2152                        @Override
2153                        public View getView(int position, View convertView, ViewGroup parent) {
2154                            Button v = (Button) super.getView(position, convertView, parent);
2155                            v.setOnClickListener((view) -> {
2156                                mValue.setContent(getItem(position).getValue());
2157                                execute();
2158                                loading = true;
2159                            });
2160
2161                            final SVG icon = getItem(position).getIcon();
2162                            if (icon != null) {
2163                                 final Element iconEl = getItem(position).getIconEl();
2164                                 if (height < 1) {
2165                                     v.measure(0, 0);
2166                                     height = v.getMeasuredHeight();
2167                                 }
2168                                 if (height < 1) return v;
2169                                 if (mediaSelector) {
2170                                     final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2171                                     if (d != null) {
2172                                         final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2173                                         d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2174                                     }
2175                                     v.setCompoundDrawables(null, d, null, null);
2176                                 } else {
2177                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2178                                 }
2179                            }
2180
2181                            return v;
2182                        }
2183                    };
2184                }
2185                protected Element mValue = null;
2186                protected ArrayAdapter<Option> options;
2187                protected Option defaultOption = null;
2188                protected boolean mediaSelector = false;
2189
2190                @Override
2191                public void bind(Item item) {
2192                    Field field = (Field) item;
2193                    setTextOrHide(binding.label, field.getLabel());
2194                    setTextOrHide(binding.desc, field.getDesc());
2195
2196                    if (field.error != null) {
2197                        binding.desc.setVisibility(View.VISIBLE);
2198                        binding.desc.setText(field.error);
2199                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
2200                    } else {
2201                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
2202                    }
2203
2204                    mValue = field.getValue();
2205                    mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2206
2207                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2208                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2209                    binding.openButton.setOnClickListener((view) -> {
2210                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2211                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2212                        builder.setPositiveButton(R.string.action_execute, null);
2213                        if (field.getDesc().isPresent()) {
2214                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2215                        }
2216                        dialogBinding.inputEditText.requestFocus();
2217                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2218                        builder.setView(dialogBinding.getRoot());
2219                        builder.setNegativeButton(R.string.cancel, null);
2220                        final AlertDialog dialog = builder.create();
2221                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2222                        dialog.show();
2223                        View.OnClickListener clickListener = v -> {
2224                            String value = dialogBinding.inputEditText.getText().toString();
2225                            mValue.setContent(value);
2226                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2227                            dialog.dismiss();
2228                            execute();
2229                            loading = true;
2230                        };
2231                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2232                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2233                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2234                            dialog.dismiss();
2235                        }));
2236                        dialog.setCanceledOnTouchOutside(false);
2237                        dialog.setOnDismissListener(dialog1 -> {
2238                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2239                        });
2240                    });
2241
2242                    options.clear();
2243                    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();
2244
2245                    defaultOption = null;
2246                    for (Option option : theOptions) {
2247                        if (option.getValue().equals(mValue.getContent())) {
2248                            defaultOption = option;
2249                            break;
2250                        }
2251                    }
2252                    if (defaultOption == null && !mValue.getContent().equals("")) {
2253                        // Synthesize default option for custom value
2254                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2255                    }
2256                    if (defaultOption == null) {
2257                        binding.defaultButton.setVisibility(View.GONE);
2258                    } else {
2259                        theOptions.remove(defaultOption);
2260                        binding.defaultButton.setVisibility(View.VISIBLE);
2261
2262                        final SVG defaultIcon = defaultOption.getIcon();
2263                        if (defaultIcon != null) {
2264                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2265                             int height = (int)(display.heightPixels*display.density/4);
2266                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2267                        }
2268
2269                        binding.defaultButton.setText(defaultOption.toString());
2270                        binding.defaultButton.setOnClickListener((view) -> {
2271                            mValue.setContent(defaultOption.getValue());
2272                            execute();
2273                            loading = true;
2274                        });
2275                    }
2276
2277                    options.addAll(theOptions);
2278                    binding.buttons.setAdapter(options);
2279                }
2280            }
2281
2282            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2283                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2284                    super(binding);
2285                    binding.textinput.addTextChangedListener(this);
2286                }
2287                protected Field field = null;
2288
2289                @Override
2290                public void bind(Item item) {
2291                    field = (Field) item;
2292                    binding.textinputLayout.setHint(field.getLabel().or(""));
2293
2294                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2295                    for (String desc : field.getDesc().asSet()) {
2296                        binding.textinputLayout.setHelperText(desc);
2297                    }
2298
2299                    binding.textinputLayout.setErrorEnabled(field.error != null);
2300                    if (field.error != null) binding.textinputLayout.setError(field.error);
2301
2302                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2303                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2304                    if (suffixLabel == null) {
2305                        binding.textinputLayout.setSuffixText("");
2306                    } else {
2307                        binding.textinputLayout.setSuffixText(suffixLabel);
2308                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2309                    }
2310
2311                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2312                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2313
2314                    binding.textinput.setText(String.join("\n", field.getValues()));
2315                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2316                }
2317
2318                @Override
2319                public void afterTextChanged(Editable s) {
2320                    if (field == null) return;
2321
2322                    field.setValues(List.of(s.toString().split("\n")));
2323                }
2324
2325                @Override
2326                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2327
2328                @Override
2329                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2330            }
2331
2332            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2333                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2334                protected String boundUrl = "";
2335
2336                @Override
2337                public void bind(Item oob) {
2338                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2339                    binding.webview.getSettings().setJavaScriptEnabled(true);
2340                    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");
2341                    binding.webview.getSettings().setDatabaseEnabled(true);
2342                    binding.webview.getSettings().setDomStorageEnabled(true);
2343                    binding.webview.setWebChromeClient(new WebChromeClient() {
2344                        @Override
2345                        public void onProgressChanged(WebView view, int newProgress) {
2346                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2347                            binding.progressbar.setProgress(newProgress);
2348                        }
2349                    });
2350                    binding.webview.setWebViewClient(new WebViewClient() {
2351                        @Override
2352                        public void onPageFinished(WebView view, String url) {
2353                            super.onPageFinished(view, url);
2354                            mTitle = view.getTitle();
2355                            ConversationPagerAdapter.this.notifyDataSetChanged();
2356                        }
2357                    });
2358                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2359                    if (!boundUrl.equals(url)) {
2360                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2361                        binding.webview.loadUrl(url);
2362                        boundUrl = url;
2363                    }
2364                }
2365
2366                class JsObject {
2367                    @JavascriptInterface
2368                    public void execute() { execute("execute"); }
2369
2370                    @JavascriptInterface
2371                    public void execute(String action) {
2372                        getView().post(() -> {
2373                            actionToWebview = null;
2374                            if(CommandSession.this.execute(action)) {
2375                                removeSession(CommandSession.this);
2376                            }
2377                        });
2378                    }
2379
2380                    @JavascriptInterface
2381                    public void preventDefault() {
2382                        actionToWebview = binding.webview;
2383                    }
2384                }
2385            }
2386
2387            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2388                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2389
2390                @Override
2391                public void bind(Item item) {
2392                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2393                }
2394            }
2395
2396            class Item {
2397                protected Element el;
2398                protected int viewType;
2399                protected String error = null;
2400
2401                Item(Element el, int viewType) {
2402                    this.el = el;
2403                    this.viewType = viewType;
2404                }
2405
2406                public boolean validate() {
2407                    error = null;
2408                    return true;
2409                }
2410            }
2411
2412            class Field extends Item {
2413                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2414
2415                @Override
2416                public boolean validate() {
2417                    if (!super.validate()) return false;
2418                    if (el.findChild("required", "jabber:x:data") == null) return true;
2419                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2420
2421                    error = "this value is required";
2422                    return false;
2423                }
2424
2425                public String getVar() {
2426                    return el.getAttribute("var");
2427                }
2428
2429                public Optional<String> getType() {
2430                    return Optional.fromNullable(el.getAttribute("type"));
2431                }
2432
2433                public Optional<String> getLabel() {
2434                    String label = el.getAttribute("label");
2435                    if (label == null) label = getVar();
2436                    return Optional.fromNullable(label);
2437                }
2438
2439                public Optional<String> getDesc() {
2440                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2441                }
2442
2443                public Element getValue() {
2444                    Element value = el.findChild("value", "jabber:x:data");
2445                    if (value == null) {
2446                        value = el.addChild("value", "jabber:x:data");
2447                    }
2448                    return value;
2449                }
2450
2451                public void setValues(List<String> values) {
2452                    for(Element child : el.getChildren()) {
2453                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2454                            el.removeChild(child);
2455                        }
2456                    }
2457
2458                    for (String value : values) {
2459                        el.addChild("value", "jabber:x:data").setContent(value);
2460                    }
2461                }
2462
2463                public List<String> getValues() {
2464                    List<String> values = new ArrayList<>();
2465                    for(Element child : el.getChildren()) {
2466                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2467                            values.add(child.getContent());
2468                        }
2469                    }
2470                    return values;
2471                }
2472
2473                public List<Option> getOptions() {
2474                    return Option.forField(el);
2475                }
2476            }
2477
2478            class Cell extends Item {
2479                protected Field reported;
2480
2481                Cell(Field reported, Element item) {
2482                    super(item, TYPE_RESULT_CELL);
2483                    this.reported = reported;
2484                }
2485            }
2486
2487            protected Field mkField(Element el) {
2488                int viewType = -1;
2489
2490                String formType = responseElement.getAttribute("type");
2491                if (formType != null) {
2492                    String fieldType = el.getAttribute("type");
2493                    if (fieldType == null) fieldType = "text-single";
2494
2495                    if (formType.equals("result") || fieldType.equals("fixed")) {
2496                        viewType = TYPE_RESULT_FIELD;
2497                    } else if (formType.equals("form")) {
2498                        if (fieldType.equals("boolean")) {
2499                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2500                                viewType = TYPE_BUTTON_GRID_FIELD;
2501                            } else {
2502                                viewType = TYPE_CHECKBOX_FIELD;
2503                            }
2504                        } else if (fieldType.equals("list-single")) {
2505                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2506                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2507                                viewType = TYPE_BUTTON_GRID_FIELD;
2508                            } else if (Option.forField(el).size() > 9) {
2509                                viewType = TYPE_SEARCH_LIST_FIELD;
2510                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2511                                viewType = TYPE_RADIO_EDIT_FIELD;
2512                            } else {
2513                                viewType = TYPE_SPINNER_FIELD;
2514                            }
2515                        } else {
2516                            viewType = TYPE_TEXT_FIELD;
2517                        }
2518                    }
2519
2520                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2521                }
2522
2523                return null;
2524            }
2525
2526            protected Item mkItem(Element el, int pos) {
2527                int viewType = -1;
2528
2529                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2530                    if (el.getName().equals("note")) {
2531                        viewType = TYPE_NOTE;
2532                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2533                        viewType = TYPE_WEB;
2534                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2535                        viewType = TYPE_NOTE;
2536                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2537                        Field field = mkField(el);
2538                        if (field != null) {
2539                            items.put(pos, field);
2540                            return field;
2541                        }
2542                    }
2543                } else if (response != null) {
2544                    viewType = TYPE_ERROR;
2545                }
2546
2547                Item item = new Item(el, viewType);
2548                items.put(pos, item);
2549                return item;
2550            }
2551
2552            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2553                protected Context ctx;
2554
2555                public ActionsAdapter(Context ctx) {
2556                    super(ctx, R.layout.simple_list_item);
2557                    this.ctx = ctx;
2558                }
2559
2560                @Override
2561                public View getView(int position, View convertView, ViewGroup parent) {
2562                    View v = super.getView(position, convertView, parent);
2563                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2564                    tv.setGravity(Gravity.CENTER);
2565                    tv.setText(getItem(position).second);
2566                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2567                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2568                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2569                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2570                    return v;
2571                }
2572
2573                public int getPosition(String s) {
2574                    for(int i = 0; i < getCount(); i++) {
2575                        if (getItem(i).first.equals(s)) return i;
2576                    }
2577                    return -1;
2578                }
2579
2580                public int countExceptCancel() {
2581                    int count = 0;
2582                    for(int i = 0; i < getCount(); i++) {
2583                        if (!getItem(i).first.equals("cancel")) count++;
2584                    }
2585                    return count;
2586                }
2587
2588                public void clearExceptCancel() {
2589                    Pair<String,String> cancelItem = null;
2590                    for(int i = 0; i < getCount(); i++) {
2591                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2592                    }
2593                    clear();
2594                    if (cancelItem != null) add(cancelItem);
2595                }
2596            }
2597
2598            final int TYPE_ERROR = 1;
2599            final int TYPE_NOTE = 2;
2600            final int TYPE_WEB = 3;
2601            final int TYPE_RESULT_FIELD = 4;
2602            final int TYPE_TEXT_FIELD = 5;
2603            final int TYPE_CHECKBOX_FIELD = 6;
2604            final int TYPE_SPINNER_FIELD = 7;
2605            final int TYPE_RADIO_EDIT_FIELD = 8;
2606            final int TYPE_RESULT_CELL = 9;
2607            final int TYPE_PROGRESSBAR = 10;
2608            final int TYPE_SEARCH_LIST_FIELD = 11;
2609            final int TYPE_ITEM_CARD = 12;
2610            final int TYPE_BUTTON_GRID_FIELD = 13;
2611
2612            protected boolean executing = false;
2613            protected boolean loading = false;
2614            protected boolean loadingHasBeenLong = false;
2615            protected Timer loadingTimer = new Timer();
2616            protected String mTitle;
2617            protected String mNode;
2618            protected CommandPageBinding mBinding = null;
2619            protected IqPacket response = null;
2620            protected Element responseElement = null;
2621            protected boolean expectingRemoval = false;
2622            protected List<Field> reported = null;
2623            protected SparseArray<Item> items = new SparseArray<>();
2624            protected XmppConnectionService xmppConnectionService;
2625            protected ActionsAdapter actionsAdapter;
2626            protected GridLayoutManager layoutManager;
2627            protected WebView actionToWebview = null;
2628            protected int fillableFieldCount = 0;
2629            protected IqPacket pendingResponsePacket = null;
2630            protected boolean waitingForRefresh = false;
2631
2632            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2633                loading();
2634                mTitle = title;
2635                mNode = node;
2636                this.xmppConnectionService = xmppConnectionService;
2637                if (mPager != null) setupLayoutManager();
2638                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2639                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2640                    @Override
2641                    public void onChanged() {
2642                        if (mBinding == null) return;
2643
2644                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2645                    }
2646
2647                    @Override
2648                    public void onInvalidated() {}
2649                });
2650            }
2651
2652            public String getTitle() {
2653                return mTitle;
2654            }
2655
2656            public String getNode() {
2657                return mNode;
2658            }
2659
2660            public void updateWithResponse(final IqPacket iq) {
2661                if (getView() != null && getView().isAttachedToWindow()) {
2662                    getView().post(() -> updateWithResponseUiThread(iq));
2663                } else {
2664                    pendingResponsePacket = iq;
2665                }
2666            }
2667
2668            protected void updateWithResponseUiThread(final IqPacket iq) {
2669                Timer oldTimer = this.loadingTimer;
2670                this.loadingTimer = new Timer();
2671                oldTimer.cancel();
2672                this.executing = false;
2673                this.loading = false;
2674                this.loadingHasBeenLong = false;
2675                this.responseElement = null;
2676                this.fillableFieldCount = 0;
2677                this.reported = null;
2678                this.response = iq;
2679                this.items.clear();
2680                this.actionsAdapter.clear();
2681                layoutManager.setSpanCount(1);
2682
2683                boolean actionsCleared = false;
2684                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2685                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2686                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2687                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2688                    }
2689
2690                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2691                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2692                    }
2693
2694                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2695                    if (actions != null) {
2696                        for (Element action : actions.getChildren()) {
2697                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2698                            if ("execute".equals(action.getName())) continue;
2699
2700                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2701                        }
2702                    }
2703
2704                    for (Element el : command.getChildren()) {
2705                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2706                            Data form = Data.parse(el);
2707                            String title = form.getTitle();
2708                            if (title != null) {
2709                                mTitle = title;
2710                                ConversationPagerAdapter.this.notifyDataSetChanged();
2711                            }
2712
2713                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2714                                this.responseElement = el;
2715                                setupReported(el.findChild("reported", "jabber:x:data"));
2716                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2717                            }
2718
2719                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2720                            if (actionList != null) {
2721                                actionsAdapter.clear();
2722
2723                                for (Option action : actionList.getOptions()) {
2724                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2725                                }
2726                            }
2727
2728                            String fillableFieldType = null;
2729                            String fillableFieldValue = null;
2730                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2731                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2732                                    fillableFieldType = field.getType();
2733                                    fillableFieldValue = field.getValue();
2734                                    fillableFieldCount++;
2735                                }
2736                            }
2737
2738                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2739                                actionsCleared = true;
2740                                actionsAdapter.clearExceptCancel();
2741                            }
2742                            break;
2743                        }
2744                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2745                            String url = el.findChildContent("url", "jabber:x:oob");
2746                            if (url != null) {
2747                                String scheme = Uri.parse(url).getScheme();
2748                                if (scheme.equals("http") || scheme.equals("https")) {
2749                                    this.responseElement = el;
2750                                    break;
2751                                }
2752                                if (scheme.equals("xmpp")) {
2753                                    expectingRemoval = true;
2754                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2755                                    intent.setAction(Intent.ACTION_VIEW);
2756                                    intent.setData(Uri.parse(url));
2757                                    getView().getContext().startActivity(intent);
2758                                    break;
2759                                }
2760                            }
2761                        }
2762                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2763                            this.responseElement = el;
2764                            break;
2765                        }
2766                    }
2767
2768                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2769                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2770                            if (xmppConnectionService.isOnboarding()) {
2771                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2772                                    xmppConnectionService.deleteAccount(getAccount());
2773                                } else {
2774                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2775                                        removeSession(this);
2776                                        return;
2777                                    } else {
2778                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2779                                        xmppConnectionService.deleteAccount(getAccount());
2780                                    }
2781                                }
2782                            }
2783                            xmppConnectionService.archiveConversation(Conversation.this);
2784                        }
2785
2786                        expectingRemoval = true;
2787                        removeSession(this);
2788                        return;
2789                    }
2790
2791                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2792                        // No actions have been given, but we are not done?
2793                        // This is probably a spec violation, but we should do *something*
2794                        actionsAdapter.add(Pair.create("execute", "execute"));
2795                    }
2796
2797                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2798                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2799                            actionsAdapter.add(Pair.create("close", "close"));
2800                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2801                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2802                        }
2803                    }
2804                }
2805
2806                if (actionsAdapter.isEmpty()) {
2807                    actionsAdapter.add(Pair.create("close", "close"));
2808                }
2809
2810                actionsAdapter.sort((x, y) -> {
2811                    if (x.first.equals("cancel")) return -1;
2812                    if (y.first.equals("cancel")) return 1;
2813                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2814                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2815                    return 0;
2816                });
2817
2818                Data dataForm = null;
2819                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2820                if (mNode.equals("jabber:iq:register") &&
2821                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
2822                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2823
2824
2825                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2826                    execute();
2827                }
2828                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2829                notifyDataSetChanged();
2830            }
2831
2832            protected void setupReported(Element el) {
2833                if (el == null) {
2834                    reported = null;
2835                    return;
2836                }
2837
2838                reported = new ArrayList<>();
2839                for (Element fieldEl : el.getChildren()) {
2840                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2841                    reported.add(mkField(fieldEl));
2842                }
2843            }
2844
2845            @Override
2846            public int getItemCount() {
2847                if (loading) return 1;
2848                if (response == null) return 0;
2849                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2850                    int i = 0;
2851                    for (Element el : responseElement.getChildren()) {
2852                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2853                        if (el.getName().equals("title")) continue;
2854                        if (el.getName().equals("field")) {
2855                            String type = el.getAttribute("type");
2856                            if (type != null && type.equals("hidden")) continue;
2857                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2858                        }
2859
2860                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2861                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2862                                if (el.getName().equals("reported")) continue;
2863                                i += 1;
2864                            } else {
2865                                if (reported != null) i += reported.size();
2866                            }
2867                            continue;
2868                        }
2869
2870                        i++;
2871                    }
2872                    return i;
2873                }
2874                return 1;
2875            }
2876
2877            public Item getItem(int position) {
2878                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2879                if (items.get(position) != null) return items.get(position);
2880                if (response == null) return null;
2881
2882                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2883                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2884                        int i = 0;
2885                        for (Element el : responseElement.getChildren()) {
2886                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2887                            if (el.getName().equals("title")) continue;
2888                            if (el.getName().equals("field")) {
2889                                String type = el.getAttribute("type");
2890                                if (type != null && type.equals("hidden")) continue;
2891                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2892                            }
2893
2894                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2895                                Cell cell = null;
2896
2897                                if (reported != null) {
2898                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2899                                        if (el.getName().equals("reported")) continue;
2900                                        if (i == position) {
2901                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2902                                            return items.get(position);
2903                                        }
2904                                    } else {
2905                                        if (reported.size() > position - i) {
2906                                            Field reportedField = reported.get(position - i);
2907                                            Element itemField = null;
2908                                            if (el.getName().equals("item")) {
2909                                                for (Element subel : el.getChildren()) {
2910                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2911                                                       itemField = subel;
2912                                                       break;
2913                                                    }
2914                                                }
2915                                            }
2916                                            cell = new Cell(reportedField, itemField);
2917                                        } else {
2918                                            i += reported.size();
2919                                            continue;
2920                                        }
2921                                    }
2922                                }
2923
2924                                if (cell != null) {
2925                                    items.put(position, cell);
2926                                    return cell;
2927                                }
2928                            }
2929
2930                            if (i < position) {
2931                                i++;
2932                                continue;
2933                            }
2934
2935                            return mkItem(el, position);
2936                        }
2937                    }
2938                }
2939
2940                return mkItem(responseElement == null ? response : responseElement, position);
2941            }
2942
2943            @Override
2944            public int getItemViewType(int position) {
2945                return getItem(position).viewType;
2946            }
2947
2948            @Override
2949            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2950                switch(viewType) {
2951                    case TYPE_ERROR: {
2952                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2953                        return new ErrorViewHolder(binding);
2954                    }
2955                    case TYPE_NOTE: {
2956                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2957                        return new NoteViewHolder(binding);
2958                    }
2959                    case TYPE_WEB: {
2960                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2961                        return new WebViewHolder(binding);
2962                    }
2963                    case TYPE_RESULT_FIELD: {
2964                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2965                        return new ResultFieldViewHolder(binding);
2966                    }
2967                    case TYPE_RESULT_CELL: {
2968                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2969                        return new ResultCellViewHolder(binding);
2970                    }
2971                    case TYPE_ITEM_CARD: {
2972                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2973                        return new ItemCardViewHolder(binding);
2974                    }
2975                    case TYPE_CHECKBOX_FIELD: {
2976                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2977                        return new CheckboxFieldViewHolder(binding);
2978                    }
2979                    case TYPE_SEARCH_LIST_FIELD: {
2980                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2981                        return new SearchListFieldViewHolder(binding);
2982                    }
2983                    case TYPE_RADIO_EDIT_FIELD: {
2984                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2985                        return new RadioEditFieldViewHolder(binding);
2986                    }
2987                    case TYPE_SPINNER_FIELD: {
2988                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2989                        return new SpinnerFieldViewHolder(binding);
2990                    }
2991                    case TYPE_BUTTON_GRID_FIELD: {
2992                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2993                        return new ButtonGridFieldViewHolder(binding);
2994                    }
2995                    case TYPE_TEXT_FIELD: {
2996                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2997                        return new TextFieldViewHolder(binding);
2998                    }
2999                    case TYPE_PROGRESSBAR: {
3000                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3001                        return new ProgressBarViewHolder(binding);
3002                    }
3003                    default:
3004                        if (expectingRemoval) {
3005                            CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3006                            return new NoteViewHolder(binding);
3007                        }
3008
3009                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3010                }
3011            }
3012
3013            @Override
3014            public void onBindViewHolder(ViewHolder viewHolder, int position) {
3015                viewHolder.bind(getItem(position));
3016            }
3017
3018            public View getView() {
3019                if (mBinding == null) return null;
3020                return mBinding.getRoot();
3021            }
3022
3023            public boolean validate() {
3024                int count = getItemCount();
3025                boolean isValid = true;
3026                for (int i = 0; i < count; i++) {
3027                    boolean oneIsValid = getItem(i).validate();
3028                    isValid = isValid && oneIsValid;
3029                }
3030                notifyDataSetChanged();
3031                return isValid;
3032            }
3033
3034            public boolean execute() {
3035                return execute("execute");
3036            }
3037
3038            public boolean execute(int actionPosition) {
3039                return execute(actionsAdapter.getItem(actionPosition).first);
3040            }
3041
3042            public synchronized boolean execute(String action) {
3043                if (!"cancel".equals(action) && executing) {
3044                    loadingHasBeenLong = true;
3045                    notifyDataSetChanged();
3046                    return false;
3047                }
3048                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3049
3050                if (response == null) return true;
3051                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3052                if (command == null) return true;
3053                String status = command.getAttribute("status");
3054                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3055
3056                if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3057                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3058                    return false;
3059                }
3060
3061                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3062                packet.setTo(response.getFrom());
3063                final Element c = packet.addChild("command", Namespace.COMMANDS);
3064                c.setAttribute("node", mNode);
3065                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3066
3067                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3068                if (!action.equals("cancel") &&
3069                    !action.equals("prev") &&
3070                    responseElement != null &&
3071                    responseElement.getName().equals("x") &&
3072                    responseElement.getNamespace().equals("jabber:x:data") &&
3073                    formType != null && formType.equals("form")) {
3074
3075                    Data form = Data.parse(responseElement);
3076                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3077                    if (actionList != null) {
3078                        actionList.setValue(action);
3079                        c.setAttribute("action", "execute");
3080                    }
3081
3082                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3083                        if (form.getValue("gateway-jid") == null) {
3084                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3085                        } else {
3086                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3087                        }
3088                    }
3089
3090                    responseElement.setAttribute("type", "submit");
3091                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3092                    if (rsm != null) {
3093                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3094                        max.setContent("1000");
3095                        rsm.addChild(max);
3096                    }
3097
3098                    c.addChild(responseElement);
3099                }
3100
3101                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3102
3103                executing = true;
3104                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3105                    updateWithResponse(iq);
3106                }, 120L);
3107
3108                loading();
3109                return false;
3110            }
3111
3112            public void refresh() {
3113                synchronized(this) {
3114                    if (waitingForRefresh) notifyDataSetChanged();
3115                }
3116            }
3117
3118            protected void loading() {
3119                View v = getView();
3120                try {
3121                    loadingTimer.schedule(new TimerTask() {
3122                        @Override
3123                        public void run() {
3124                            View v2 = getView();
3125                            loading = true;
3126
3127                            loadingTimer.schedule(new TimerTask() {
3128                                @Override
3129                                public void run() {
3130                                    loadingHasBeenLong = true;
3131                                    if (v == null && v2 == null) return;
3132                                    (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3133                                }
3134                            }, 3000);
3135
3136                            if (v == null && v2 == null) return;
3137                            (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3138                        }
3139                    }, 500);
3140                } catch (final IllegalStateException e) { }
3141            }
3142
3143            protected GridLayoutManager setupLayoutManager() {
3144                int spanCount = 1;
3145
3146                Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3147                if (reported != null) {
3148                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3149                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3150                    float tableHeaderWidth = reported.stream().reduce(
3151                        0f,
3152                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3153                        (a, b) -> a + b
3154                    );
3155
3156                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3157                }
3158
3159                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3160                    items.clear();
3161                    notifyDataSetChanged();
3162                }
3163
3164                layoutManager = new GridLayoutManager(ctx, spanCount);
3165                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3166                    @Override
3167                    public int getSpanSize(int position) {
3168                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3169                        return 1;
3170                    }
3171                });
3172                return layoutManager;
3173            }
3174
3175            protected void setBinding(CommandPageBinding b) {
3176                mBinding = b;
3177                // https://stackoverflow.com/a/32350474/8611
3178                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3179                    @Override
3180                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3181                        if(rv.getChildCount() > 0) {
3182                            int[] location = new int[2];
3183                            rv.getLocationOnScreen(location);
3184                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3185                            if (childView instanceof ViewGroup) {
3186                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3187                            }
3188                            int action = e.getAction();
3189                            switch (action) {
3190                                case MotionEvent.ACTION_DOWN:
3191                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3192                                        rv.requestDisallowInterceptTouchEvent(true);
3193                                    }
3194                                case MotionEvent.ACTION_UP:
3195                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3196                                        rv.requestDisallowInterceptTouchEvent(true);
3197                                    }
3198                            }
3199                        }
3200
3201                        return false;
3202                    }
3203
3204                    @Override
3205                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3206
3207                    @Override
3208                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3209                });
3210                mBinding.form.setLayoutManager(setupLayoutManager());
3211                mBinding.form.setAdapter(this);
3212                mBinding.actions.setAdapter(actionsAdapter);
3213                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3214                    if (execute(pos)) {
3215                        removeSession(CommandSession.this);
3216                    }
3217                });
3218
3219                actionsAdapter.notifyDataSetChanged();
3220
3221                if (pendingResponsePacket != null) {
3222                    final IqPacket pending = pendingResponsePacket;
3223                    pendingResponsePacket = null;
3224                    updateWithResponseUiThread(pending);
3225                }
3226            }
3227
3228            private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3229               if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image"))  {
3230                   return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3231               } else {
3232                   return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3233               }
3234            }
3235
3236            private Drawable getDrawableForUrl(final String url) {
3237                final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3238                final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3239                final Drawable d = cache.get(url);
3240                if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3241                if (d == null) {
3242                    synchronized (CommandSession.this) {
3243                        waitingForRefresh = true;
3244                    }
3245                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3246                    Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3247                    dummy.setFileParams(new Message.FileParams(url));
3248                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3249                        if (file == null) {
3250                            dummy.getTransferable().start();
3251                        } else {
3252                            try {
3253                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3254                            } catch (final Exception e) { }
3255                        }
3256                    });
3257                }
3258                return d;
3259            }
3260
3261            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3262                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3263                setBinding(binding);
3264                return binding.getRoot();
3265            }
3266
3267            // https://stackoverflow.com/a/36037991/8611
3268            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3269                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3270                    View child = viewGroup.getChildAt(i);
3271                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3272                        View foundView = findViewAt((ViewGroup) child, x, y);
3273                        if (foundView != null && foundView.isShown()) {
3274                            return foundView;
3275                        }
3276                    } else {
3277                        int[] location = new int[2];
3278                        child.getLocationOnScreen(location);
3279                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3280                        if (rect.contains((int)x, (int)y)) {
3281                            return child;
3282                        }
3283                    }
3284                }
3285
3286                return null;
3287            }
3288        }
3289    }
3290}