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