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