Conversation.java

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