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