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