Conversation.java

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