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