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