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