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            if (position-2 > sessions.size()) return null;
1698            ConversationPage session = sessions.get(position-2);
1699            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1700            if (v != null && v.getParent() != null) {
1701                ((ViewGroup) v.getParent()).removeView(v);
1702            }
1703            container.addView(v);
1704            return session;
1705        }
1706
1707        @Override
1708        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1709            if (position < 2) {
1710                container.removeView((View) o);
1711                return;
1712            }
1713
1714            container.removeView(((ConversationPage) o).getView());
1715        }
1716
1717        @Override
1718        public int getItemPosition(Object o) {
1719            if (mPager != null) {
1720                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1721                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1722            }
1723
1724            int pos = sessions == null ? -1 : sessions.indexOf(o);
1725            if (pos < 0) return PagerAdapter.POSITION_NONE;
1726            return pos + 2;
1727        }
1728
1729        @Override
1730        public int getCount() {
1731            if (sessions == null) return 1;
1732
1733            int count = 2 + sessions.size();
1734            if (mTabs == null) return count;
1735
1736            if (count > 2) {
1737                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1738            } else {
1739                mTabs.setTabMode(TabLayout.MODE_FIXED);
1740            }
1741            return count;
1742        }
1743
1744        @Override
1745        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1746            if (view == o) return true;
1747
1748            if (o instanceof ConversationPage) {
1749                return ((ConversationPage) o).getView() == view;
1750            }
1751
1752            return false;
1753        }
1754
1755        @Nullable
1756        @Override
1757        public CharSequence getPageTitle(int position) {
1758            switch (position) {
1759                case 0:
1760                    return "Conversation";
1761                case 1:
1762                    return "Commands";
1763                default:
1764                    ConversationPage session = sessions.get(position-2);
1765                    if (session == null) return super.getPageTitle(position);
1766                    return session.getTitle();
1767            }
1768        }
1769
1770        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1771            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1772                protected T binding;
1773
1774                public ViewHolder(T binding) {
1775                    super(binding.getRoot());
1776                    this.binding = binding;
1777                }
1778
1779                abstract public void bind(Item el);
1780
1781                protected void setTextOrHide(TextView v, Optional<String> s) {
1782                    if (s == null || !s.isPresent()) {
1783                        v.setVisibility(View.GONE);
1784                    } else {
1785                        v.setVisibility(View.VISIBLE);
1786                        v.setText(s.get());
1787                    }
1788                }
1789
1790                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1791                    int flags = 0;
1792                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1793                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1794
1795                    String type = field.getAttribute("type");
1796                    if (type != null) {
1797                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1798                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1799                        }
1800
1801                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1802
1803                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1804                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1805                        }
1806
1807                        if (type.equals("text-private")) {
1808                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1809                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1810                        }
1811                    }
1812
1813                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1814                    if (validate == null) return;
1815                    String datatype = validate.getAttribute("datatype");
1816                    if (datatype == null) return;
1817
1818                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1819                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1820                    }
1821
1822                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1823                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1824                    }
1825
1826                    if (datatype.equals("xs:date")) {
1827                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1828                    }
1829
1830                    if (datatype.equals("xs:dateTime")) {
1831                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1832                    }
1833
1834                    if (datatype.equals("xs:time")) {
1835                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1836                    }
1837
1838                    if (datatype.equals("xs:anyURI")) {
1839                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1840                    }
1841
1842                    if (datatype.equals("html:tel")) {
1843                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1844                    }
1845
1846                    if (datatype.equals("html:email")) {
1847                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1848                    }
1849                }
1850
1851                protected String formatValue(String datatype, String value, boolean compact) {
1852                    if ("xs:dateTime".equals(datatype)) {
1853                        ZonedDateTime zonedDateTime = null;
1854                        try {
1855                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1856                        } catch (final DateTimeParseException e) {
1857                            try {
1858                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1859                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
1860                            } catch (final DateTimeParseException e2) { }
1861                        }
1862                        if (zonedDateTime == null) return value;
1863                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1864                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1865                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
1866                    }
1867
1868                    if ("html:tel".equals(datatype) && !compact) {
1869                        return PhoneNumberUtils.formatNumber(value, value, null);
1870                    }
1871
1872                    return value;
1873                }
1874            }
1875
1876            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1877                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1878
1879                @Override
1880                public void bind(Item iq) {
1881                    binding.errorIcon.setVisibility(View.VISIBLE);
1882
1883                    if (iq == null || iq.el == null) return;
1884                    Element error = iq.el.findChild("error");
1885                    if (error == null) {
1886                        binding.message.setText("Unexpected response: " + iq);
1887                        return;
1888                    }
1889                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1890                    if (text == null || text.equals("")) {
1891                        text = error.getChildren().get(0).getName();
1892                    }
1893                    binding.message.setText(text);
1894                }
1895            }
1896
1897            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1898                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1899
1900                @Override
1901                public void bind(Item note) {
1902                    binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1903
1904                    String type = note.el.getAttribute("type");
1905                    if (type != null && type.equals("error")) {
1906                        binding.errorIcon.setVisibility(View.VISIBLE);
1907                    }
1908                }
1909            }
1910
1911            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1912                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1913
1914                @Override
1915                public void bind(Item item) {
1916                    Field field = (Field) item;
1917                    setTextOrHide(binding.label, field.getLabel());
1918                    setTextOrHide(binding.desc, field.getDesc());
1919
1920                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
1921                    if (media == null) {
1922                        binding.mediaImage.setVisibility(View.GONE);
1923                    } else {
1924                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1925                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1926                        for (Element uriEl : media.getChildren()) {
1927                            if (!"uri".equals(uriEl.getName())) continue;
1928                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1929                            String mimeType = uriEl.getAttribute("type");
1930                            String uriS = uriEl.getContent();
1931                            if (mimeType == null || uriS == null) continue;
1932                            Uri uri = Uri.parse(uriS);
1933                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1934                                final Drawable d = getDrawableForUrl(uri.toString());
1935                                if (d != null) {
1936                                    binding.mediaImage.setImageDrawable(d);
1937                                    binding.mediaImage.setVisibility(View.VISIBLE);
1938                                }
1939                            }
1940                        }
1941                    }
1942
1943                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1944                    String datatype = validate == null ? null : validate.getAttribute("datatype");
1945
1946                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1947                    for (Element el : field.el.getChildren()) {
1948                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1949                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1950                        }
1951                    }
1952                    binding.values.setAdapter(values);
1953                    Util.justifyListViewHeightBasedOnChildren(binding.values);
1954
1955                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1956                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1957                            new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
1958                        });
1959                    } else if ("xs:anyURI".equals(datatype)) {
1960                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1961                            new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1962                        });
1963                    } else if ("html:tel".equals(datatype)) {
1964                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1965                            try {
1966                                new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1967                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1968                        });
1969                    }
1970
1971                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1972                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1973                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1974                        }
1975                        return true;
1976                    });
1977                }
1978            }
1979
1980            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1981                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1982
1983                @Override
1984                public void bind(Item item) {
1985                    Cell cell = (Cell) item;
1986
1987                    if (cell.el == null) {
1988                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
1989                        setTextOrHide(binding.text, cell.reported.getLabel());
1990                    } else {
1991                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1992                        String datatype = validate == null ? null : validate.getAttribute("datatype");
1993                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1994                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1995                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1996                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1997                        } else if ("xs:anyURI".equals(datatype)) {
1998                            text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1999                        } else if ("html:tel".equals(datatype)) {
2000                            try {
2001                                text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2002                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2003                        }
2004
2005                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2006                        binding.text.setText(text);
2007
2008                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2009                        method.setOnLinkLongClickListener((tv, url) -> {
2010                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2011                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2012                            return true;
2013                        });
2014                        binding.text.setMovementMethod(method);
2015                    }
2016                }
2017            }
2018
2019            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2020                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2021
2022                @Override
2023                public void bind(Item item) {
2024                    binding.fields.removeAllViews();
2025
2026                    for (Field field : reported) {
2027                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2028                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2029                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2030                        param.width = 0;
2031                        row.getRoot().setLayoutParams(param);
2032                        binding.fields.addView(row.getRoot());
2033                        for (Element el : item.el.getChildren()) {
2034                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2035                                for (String label : field.getLabel().asSet()) {
2036                                    el.setAttribute("label", label);
2037                                }
2038                                for (String desc : field.getDesc().asSet()) {
2039                                    el.setAttribute("desc", desc);
2040                                }
2041                                for (String type : field.getType().asSet()) {
2042                                    el.setAttribute("type", type);
2043                                }
2044                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2045                                if (validate != null) el.addChild(validate);
2046                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2047                            }
2048                        }
2049                    }
2050                }
2051            }
2052
2053            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2054                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2055                    super(binding);
2056                    binding.row.setOnClickListener((v) -> {
2057                        binding.checkbox.toggle();
2058                    });
2059                    binding.checkbox.setOnCheckedChangeListener(this);
2060                }
2061                protected Element mValue = null;
2062
2063                @Override
2064                public void bind(Item item) {
2065                    Field field = (Field) item;
2066                    binding.label.setText(field.getLabel().or(""));
2067                    setTextOrHide(binding.desc, field.getDesc());
2068                    mValue = field.getValue();
2069                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
2070                }
2071
2072                @Override
2073                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2074                    if (mValue == null) return;
2075
2076                    mValue.setContent(isChecked ? "true" : "false");
2077                }
2078            }
2079
2080            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2081                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2082                    super(binding);
2083                    binding.search.addTextChangedListener(this);
2084                }
2085                protected Field field = null;
2086                Set<String> filteredValues;
2087                List<Option> options = new ArrayList<>();
2088                protected ArrayAdapter<Option> adapter;
2089                protected boolean open;
2090                protected boolean multi;
2091                protected int textColor = -1;
2092
2093                @Override
2094                public void bind(Item item) {
2095                    ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2096                    final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2097                    if (fillableFieldCount > 1) {
2098                        layout.height = (int) (density * 200);
2099                    } else {
2100                        layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2101                    }
2102                    binding.list.setLayoutParams(layout);
2103
2104                    field = (Field) item;
2105                    setTextOrHide(binding.label, field.getLabel());
2106                    setTextOrHide(binding.desc, field.getDesc());
2107
2108                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2109                    if (field.error != null) {
2110                        binding.desc.setVisibility(View.VISIBLE);
2111                        binding.desc.setText(field.error);
2112                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2113                    } else {
2114                        binding.desc.setTextColor(textColor);
2115                    }
2116
2117                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2118                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2119                    setupInputType(field.el, binding.search, null);
2120
2121                    multi = field.getType().equals(Optional.of("list-multi"));
2122                    if (multi) {
2123                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2124                    } else {
2125                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2126                    }
2127
2128                    options = field.getOptions();
2129                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
2130                        Set<String> values = new HashSet<>();
2131                        if (multi) {
2132                            values.addAll(field.getValues());
2133                            for (final String value : field.getValues()) {
2134                                if (filteredValues.contains(value)) {
2135                                    values.remove(value);
2136                                }
2137                            }
2138                        }
2139
2140                        SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2141                        for (int i = 0; i < positions.size(); i++) {
2142                            if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2143                        }
2144                        field.setValues(values);
2145
2146                        if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2147                    });
2148                    search("");
2149                }
2150
2151                @Override
2152                public void afterTextChanged(Editable s) {
2153                    if (!multi && open) field.setValues(List.of(s.toString()));
2154                    search(s.toString());
2155                }
2156
2157                @Override
2158                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2159
2160                @Override
2161                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2162
2163                protected void search(String s) {
2164                    List<Option> filteredOptions;
2165                    final String q = s.replaceAll("\\W", "").toLowerCase();
2166                    if (q == null || q.equals("")) {
2167                        filteredOptions = options;
2168                    } else {
2169                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2170                    }
2171                    filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2172                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2173                    binding.list.setAdapter(adapter);
2174
2175                    for (final String value : field.getValues()) {
2176                        int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2177                        if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2178                    }
2179                }
2180            }
2181
2182            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2183                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2184                    super(binding);
2185                    binding.open.addTextChangedListener(this);
2186                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2187                        @Override
2188                        public View getView(int position, View convertView, ViewGroup parent) {
2189                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2190                            v.setId(position);
2191                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2192                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2193                            return v;
2194                        }
2195                    };
2196                }
2197                protected Element mValue = null;
2198                protected ArrayAdapter<Option> options;
2199                protected int textColor = -1;
2200
2201                @Override
2202                public void bind(Item item) {
2203                    Field field = (Field) item;
2204                    setTextOrHide(binding.label, field.getLabel());
2205                    setTextOrHide(binding.desc, field.getDesc());
2206
2207                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2208                    if (field.error != null) {
2209                        binding.desc.setVisibility(View.VISIBLE);
2210                        binding.desc.setText(field.error);
2211                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2212                    } else {
2213                        binding.desc.setTextColor(textColor);
2214                    }
2215
2216                    mValue = field.getValue();
2217
2218                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2219                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2220                    binding.open.setText(mValue.getContent());
2221                    setupInputType(field.el, binding.open, null);
2222
2223                    options.clear();
2224                    List<Option> theOptions = field.getOptions();
2225                    options.addAll(theOptions);
2226
2227                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2228                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2229                    float maxColumnWidth = theOptions.stream().map((x) ->
2230                        StaticLayout.getDesiredWidth(x.toString(), paint)
2231                    ).max(Float::compare).orElse(new Float(0.0));
2232                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2233                        binding.radios.setNumColumns(theOptions.size());
2234                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2235                        binding.radios.setNumColumns(theOptions.size() / 2);
2236                    } else {
2237                        binding.radios.setNumColumns(1);
2238                    }
2239                    binding.radios.setAdapter(options);
2240                }
2241
2242                @Override
2243                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2244                    if (mValue == null) return;
2245
2246                    if (isChecked) {
2247                        mValue.setContent(options.getItem(radio.getId()).getValue());
2248                        binding.open.setText(mValue.getContent());
2249                    }
2250                    options.notifyDataSetChanged();
2251                }
2252
2253                @Override
2254                public void afterTextChanged(Editable s) {
2255                    if (mValue == null) return;
2256
2257                    mValue.setContent(s.toString());
2258                    options.notifyDataSetChanged();
2259                }
2260
2261                @Override
2262                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2263
2264                @Override
2265                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2266            }
2267
2268            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2269                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2270                    super(binding);
2271                    binding.spinner.setOnItemSelectedListener(this);
2272                }
2273                protected Element mValue = null;
2274
2275                @Override
2276                public void bind(Item item) {
2277                    Field field = (Field) item;
2278                    setTextOrHide(binding.label, field.getLabel());
2279                    binding.spinner.setPrompt(field.getLabel().or(""));
2280                    setTextOrHide(binding.desc, field.getDesc());
2281
2282                    mValue = field.getValue();
2283
2284                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2285                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2286                    options.addAll(field.getOptions());
2287
2288                    binding.spinner.setAdapter(options);
2289                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2290                }
2291
2292                @Override
2293                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2294                    Option o = (Option) parent.getItemAtPosition(pos);
2295                    if (mValue == null) return;
2296
2297                    mValue.setContent(o == null ? "" : o.getValue());
2298                }
2299
2300                @Override
2301                public void onNothingSelected(AdapterView<?> parent) {
2302                    mValue.setContent("");
2303                }
2304            }
2305
2306            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2307                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2308                    super(binding);
2309                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2310                        protected int height = 0;
2311
2312                        @Override
2313                        public View getView(int position, View convertView, ViewGroup parent) {
2314                            Button v = (Button) super.getView(position, convertView, parent);
2315                            v.setOnClickListener((view) -> {
2316                                mValue.setContent(getItem(position).getValue());
2317                                execute();
2318                                loading = true;
2319                            });
2320
2321                            final SVG icon = getItem(position).getIcon();
2322                            if (icon != null) {
2323                                 final Element iconEl = getItem(position).getIconEl();
2324                                 if (height < 1) {
2325                                     v.measure(0, 0);
2326                                     height = v.getMeasuredHeight();
2327                                 }
2328                                 if (height < 1) return v;
2329                                 if (mediaSelector) {
2330                                     final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2331                                     if (d != null) {
2332                                         final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2333                                         d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2334                                     }
2335                                     v.setCompoundDrawables(null, d, null, null);
2336                                 } else {
2337                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2338                                 }
2339                            }
2340
2341                            return v;
2342                        }
2343                    };
2344                }
2345                protected Element mValue = null;
2346                protected ArrayAdapter<Option> options;
2347                protected Option defaultOption = null;
2348                protected boolean mediaSelector = false;
2349                protected int textColor = -1;
2350
2351                @Override
2352                public void bind(Item item) {
2353                    Field field = (Field) item;
2354                    setTextOrHide(binding.label, field.getLabel());
2355                    setTextOrHide(binding.desc, field.getDesc());
2356
2357                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2358                    if (field.error != null) {
2359                        binding.desc.setVisibility(View.VISIBLE);
2360                        binding.desc.setText(field.error);
2361                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2362                    } else {
2363                        binding.desc.setTextColor(textColor);
2364                    }
2365
2366                    mValue = field.getValue();
2367                    mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2368
2369                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2370                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2371                    binding.openButton.setOnClickListener((view) -> {
2372                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2373                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2374                        builder.setPositiveButton(R.string.action_execute, null);
2375                        if (field.getDesc().isPresent()) {
2376                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2377                        }
2378                        dialogBinding.inputEditText.requestFocus();
2379                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2380                        builder.setView(dialogBinding.getRoot());
2381                        builder.setNegativeButton(R.string.cancel, null);
2382                        final AlertDialog dialog = builder.create();
2383                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2384                        dialog.show();
2385                        View.OnClickListener clickListener = v -> {
2386                            String value = dialogBinding.inputEditText.getText().toString();
2387                            mValue.setContent(value);
2388                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2389                            dialog.dismiss();
2390                            execute();
2391                            loading = true;
2392                        };
2393                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2394                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2395                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2396                            dialog.dismiss();
2397                        }));
2398                        dialog.setCanceledOnTouchOutside(false);
2399                        dialog.setOnDismissListener(dialog1 -> {
2400                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2401                        });
2402                    });
2403
2404                    options.clear();
2405                    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();
2406
2407                    defaultOption = null;
2408                    for (Option option : theOptions) {
2409                        if (option.getValue().equals(mValue.getContent())) {
2410                            defaultOption = option;
2411                            break;
2412                        }
2413                    }
2414                    if (defaultOption == null && !mValue.getContent().equals("")) {
2415                        // Synthesize default option for custom value
2416                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2417                    }
2418                    if (defaultOption == null) {
2419                        binding.defaultButton.setVisibility(View.GONE);
2420                    } else {
2421                        theOptions.remove(defaultOption);
2422                        binding.defaultButton.setVisibility(View.VISIBLE);
2423
2424                        final SVG defaultIcon = defaultOption.getIcon();
2425                        if (defaultIcon != null) {
2426                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2427                             int height = (int)(display.heightPixels*display.density/4);
2428                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2429                        }
2430
2431                        binding.defaultButton.setText(defaultOption.toString());
2432                        binding.defaultButton.setOnClickListener((view) -> {
2433                            mValue.setContent(defaultOption.getValue());
2434                            execute();
2435                            loading = true;
2436                        });
2437                    }
2438
2439                    options.addAll(theOptions);
2440                    binding.buttons.setAdapter(options);
2441                }
2442            }
2443
2444            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2445                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2446                    super(binding);
2447                    binding.textinput.addTextChangedListener(this);
2448                }
2449                protected Field field = null;
2450
2451                @Override
2452                public void bind(Item item) {
2453                    field = (Field) item;
2454                    binding.textinputLayout.setHint(field.getLabel().or(""));
2455
2456                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2457                    for (String desc : field.getDesc().asSet()) {
2458                        binding.textinputLayout.setHelperText(desc);
2459                    }
2460
2461                    binding.textinputLayout.setErrorEnabled(field.error != null);
2462                    if (field.error != null) binding.textinputLayout.setError(field.error);
2463
2464                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2465                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2466                    if (suffixLabel == null) {
2467                        binding.textinputLayout.setSuffixText("");
2468                    } else {
2469                        binding.textinputLayout.setSuffixText(suffixLabel);
2470                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2471                    }
2472
2473                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2474                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2475
2476                    binding.textinput.setText(String.join("\n", field.getValues()));
2477                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2478                }
2479
2480                @Override
2481                public void afterTextChanged(Editable s) {
2482                    if (field == null) return;
2483
2484                    field.setValues(List.of(s.toString().split("\n")));
2485                }
2486
2487                @Override
2488                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2489
2490                @Override
2491                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2492            }
2493
2494            class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2495                public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2496                protected Field field = null;
2497
2498                @Override
2499                public void bind(Item item) {
2500                    field = (Field) item;
2501                    setTextOrHide(binding.label, field.getLabel());
2502                    setTextOrHide(binding.desc, field.getDesc());
2503                    final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2504                    final String datatype = validate == null ? null : validate.getAttribute("datatype");
2505                    final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2506                    // NOTE: range also implies open, so we don't have to be bound by the options strictly
2507                    // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2508                    Float min = null;
2509                    try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2510                    Float max = null;
2511                    try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max"));  } catch (NumberFormatException e) { }
2512
2513                    List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2514                    Collections.sort(options);
2515                    if (options.size() > 0) {
2516                        // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2517                        if (min == null) min = options.get(0);
2518                        if (max == null) max = options.get(options.size()-1);
2519                    }
2520
2521                    if (field.getValues().size() > 0) {
2522                        binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2523                    } else {
2524                        binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2525                    }
2526                    binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2527                    binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2528                    if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2529                        binding.slider.setStepSize(1);
2530                    } else {
2531                        binding.slider.setStepSize(0);
2532                    }
2533
2534                    if (options.size() > 0) {
2535                        float step = -1;
2536                        Float prev = null;
2537                        for (final Float option : options) {
2538                            if (prev != null) {
2539                                float nextStep = option - prev;
2540                                if (step > 0 && step != nextStep) {
2541                                    step = -1;
2542                                    break;
2543                                }
2544                                step = nextStep;
2545                            }
2546                            prev = option;
2547                        }
2548                        if (step > 0) binding.slider.setStepSize(step);
2549                    }
2550
2551                    binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2552                        field.setValues(List.of(new DecimalFormat().format(value)));
2553                    });
2554                }
2555            }
2556
2557            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2558                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2559                protected String boundUrl = "";
2560
2561                @Override
2562                public void bind(Item oob) {
2563                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2564                    binding.webview.getSettings().setJavaScriptEnabled(true);
2565                    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");
2566                    binding.webview.getSettings().setDatabaseEnabled(true);
2567                    binding.webview.getSettings().setDomStorageEnabled(true);
2568                    binding.webview.setWebChromeClient(new WebChromeClient() {
2569                        @Override
2570                        public void onProgressChanged(WebView view, int newProgress) {
2571                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2572                            binding.progressbar.setProgress(newProgress);
2573                        }
2574                    });
2575                    binding.webview.setWebViewClient(new WebViewClient() {
2576                        @Override
2577                        public void onPageFinished(WebView view, String url) {
2578                            super.onPageFinished(view, url);
2579                            mTitle = view.getTitle();
2580                            ConversationPagerAdapter.this.notifyDataSetChanged();
2581                        }
2582                    });
2583                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2584                    if (!boundUrl.equals(url)) {
2585                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2586                        binding.webview.loadUrl(url);
2587                        boundUrl = url;
2588                    }
2589                }
2590
2591                class JsObject {
2592                    @JavascriptInterface
2593                    public void execute() { execute("execute"); }
2594
2595                    @JavascriptInterface
2596                    public void execute(String action) {
2597                        getView().post(() -> {
2598                            actionToWebview = null;
2599                            if(CommandSession.this.execute(action)) {
2600                                removeSession(CommandSession.this);
2601                            }
2602                        });
2603                    }
2604
2605                    @JavascriptInterface
2606                    public void preventDefault() {
2607                        actionToWebview = binding.webview;
2608                    }
2609                }
2610            }
2611
2612            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2613                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2614
2615                @Override
2616                public void bind(Item item) {
2617                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2618                }
2619            }
2620
2621            class Item {
2622                protected Element el;
2623                protected int viewType;
2624                protected String error = null;
2625
2626                Item(Element el, int viewType) {
2627                    this.el = el;
2628                    this.viewType = viewType;
2629                }
2630
2631                public boolean validate() {
2632                    error = null;
2633                    return true;
2634                }
2635            }
2636
2637            class Field extends Item {
2638                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2639
2640                @Override
2641                public boolean validate() {
2642                    if (!super.validate()) return false;
2643                    if (el.findChild("required", "jabber:x:data") == null) return true;
2644                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2645
2646                    error = "this value is required";
2647                    return false;
2648                }
2649
2650                public String getVar() {
2651                    return el.getAttribute("var");
2652                }
2653
2654                public Optional<String> getType() {
2655                    return Optional.fromNullable(el.getAttribute("type"));
2656                }
2657
2658                public Optional<String> getLabel() {
2659                    String label = el.getAttribute("label");
2660                    if (label == null) label = getVar();
2661                    return Optional.fromNullable(label);
2662                }
2663
2664                public Optional<String> getDesc() {
2665                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2666                }
2667
2668                public Element getValue() {
2669                    Element value = el.findChild("value", "jabber:x:data");
2670                    if (value == null) {
2671                        value = el.addChild("value", "jabber:x:data");
2672                    }
2673                    return value;
2674                }
2675
2676                public void setValues(Collection<String> values) {
2677                    for(Element child : el.getChildren()) {
2678                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2679                            el.removeChild(child);
2680                        }
2681                    }
2682
2683                    for (String value : values) {
2684                        el.addChild("value", "jabber:x:data").setContent(value);
2685                    }
2686                }
2687
2688                public List<String> getValues() {
2689                    List<String> values = new ArrayList<>();
2690                    for(Element child : el.getChildren()) {
2691                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2692                            values.add(child.getContent());
2693                        }
2694                    }
2695                    return values;
2696                }
2697
2698                public List<Option> getOptions() {
2699                    return Option.forField(el);
2700                }
2701            }
2702
2703            class Cell extends Item {
2704                protected Field reported;
2705
2706                Cell(Field reported, Element item) {
2707                    super(item, TYPE_RESULT_CELL);
2708                    this.reported = reported;
2709                }
2710            }
2711
2712            protected Field mkField(Element el) {
2713                int viewType = -1;
2714
2715                String formType = responseElement.getAttribute("type");
2716                if (formType != null) {
2717                    String fieldType = el.getAttribute("type");
2718                    if (fieldType == null) fieldType = "text-single";
2719
2720                    if (formType.equals("result") || fieldType.equals("fixed")) {
2721                        viewType = TYPE_RESULT_FIELD;
2722                    } else if (formType.equals("form")) {
2723                        final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2724                        final String datatype = validate == null ? null : validate.getAttribute("datatype");
2725                        final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2726                        if (fieldType.equals("boolean")) {
2727                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2728                                viewType = TYPE_BUTTON_GRID_FIELD;
2729                            } else {
2730                                viewType = TYPE_CHECKBOX_FIELD;
2731                            }
2732                        } else if (
2733                            range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2734                                "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2735                                "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2736                            )
2737                        ) {
2738                            // has a range and is numeric, use a slider
2739                            viewType = TYPE_SLIDER_FIELD;
2740                        } else if (fieldType.equals("list-single")) {
2741                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2742                                viewType = TYPE_BUTTON_GRID_FIELD;
2743                            } else if (Option.forField(el).size() > 9) {
2744                                viewType = TYPE_SEARCH_LIST_FIELD;
2745                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2746                                viewType = TYPE_RADIO_EDIT_FIELD;
2747                            } else {
2748                                viewType = TYPE_SPINNER_FIELD;
2749                            }
2750                        } else if (fieldType.equals("list-multi")) {
2751                            viewType = TYPE_SEARCH_LIST_FIELD;
2752                        } else {
2753                            viewType = TYPE_TEXT_FIELD;
2754                        }
2755                    }
2756
2757                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2758                }
2759
2760                return null;
2761            }
2762
2763            protected Item mkItem(Element el, int pos) {
2764                int viewType = TYPE_ERROR;
2765
2766                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2767                    if (el.getName().equals("note")) {
2768                        viewType = TYPE_NOTE;
2769                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2770                        viewType = TYPE_WEB;
2771                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2772                        viewType = TYPE_NOTE;
2773                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2774                        Field field = mkField(el);
2775                        if (field != null) {
2776                            items.put(pos, field);
2777                            return field;
2778                        }
2779                    }
2780                }
2781
2782                Item item = new Item(el, viewType);
2783                items.put(pos, item);
2784                return item;
2785            }
2786
2787            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2788                protected Context ctx;
2789
2790                public ActionsAdapter(Context ctx) {
2791                    super(ctx, R.layout.simple_list_item);
2792                    this.ctx = ctx;
2793                }
2794
2795                @Override
2796                public View getView(int position, View convertView, ViewGroup parent) {
2797                    View v = super.getView(position, convertView, parent);
2798                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2799                    tv.setGravity(Gravity.CENTER);
2800                    tv.setText(getItem(position).second);
2801                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2802                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2803                    final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
2804                    tv.setTextColor(colors.getOnAccent());
2805                    tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
2806                    return v;
2807                }
2808
2809                public int getPosition(String s) {
2810                    for(int i = 0; i < getCount(); i++) {
2811                        if (getItem(i).first.equals(s)) return i;
2812                    }
2813                    return -1;
2814                }
2815
2816                public int countProceed() {
2817                    int count = 0;
2818                    for(int i = 0; i < getCount(); i++) {
2819                        if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2820                    }
2821                    return count;
2822                }
2823
2824                public int countExceptCancel() {
2825                    int count = 0;
2826                    for(int i = 0; i < getCount(); i++) {
2827                        if (!getItem(i).first.equals("cancel")) count++;
2828                    }
2829                    return count;
2830                }
2831
2832                public void clearProceed() {
2833                    Pair<String,String> cancelItem = null;
2834                    Pair<String,String> prevItem = null;
2835                    for(int i = 0; i < getCount(); i++) {
2836                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2837                        if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2838                    }
2839                    clear();
2840                    if (cancelItem != null) add(cancelItem);
2841                    if (prevItem != null) add(prevItem);
2842                }
2843            }
2844
2845            final int TYPE_ERROR = 1;
2846            final int TYPE_NOTE = 2;
2847            final int TYPE_WEB = 3;
2848            final int TYPE_RESULT_FIELD = 4;
2849            final int TYPE_TEXT_FIELD = 5;
2850            final int TYPE_CHECKBOX_FIELD = 6;
2851            final int TYPE_SPINNER_FIELD = 7;
2852            final int TYPE_RADIO_EDIT_FIELD = 8;
2853            final int TYPE_RESULT_CELL = 9;
2854            final int TYPE_PROGRESSBAR = 10;
2855            final int TYPE_SEARCH_LIST_FIELD = 11;
2856            final int TYPE_ITEM_CARD = 12;
2857            final int TYPE_BUTTON_GRID_FIELD = 13;
2858            final int TYPE_SLIDER_FIELD = 14;
2859
2860            protected boolean executing = false;
2861            protected boolean loading = false;
2862            protected boolean loadingHasBeenLong = false;
2863            protected Timer loadingTimer = new Timer();
2864            protected String mTitle;
2865            protected String mNode;
2866            protected CommandPageBinding mBinding = null;
2867            protected IqPacket response = null;
2868            protected Element responseElement = null;
2869            protected boolean expectingRemoval = false;
2870            protected List<Field> reported = null;
2871            protected SparseArray<Item> items = new SparseArray<>();
2872            protected XmppConnectionService xmppConnectionService;
2873            protected ActionsAdapter actionsAdapter = null;
2874            protected GridLayoutManager layoutManager;
2875            protected WebView actionToWebview = null;
2876            protected int fillableFieldCount = 0;
2877            protected IqPacket pendingResponsePacket = null;
2878            protected boolean waitingForRefresh = false;
2879
2880            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2881                loading();
2882                mTitle = title;
2883                mNode = node;
2884                this.xmppConnectionService = xmppConnectionService;
2885                if (mPager != null) setupLayoutManager(mPager.getContext());
2886            }
2887
2888            public String getTitle() {
2889                return mTitle;
2890            }
2891
2892            public String getNode() {
2893                return mNode;
2894            }
2895
2896            public void updateWithResponse(final IqPacket iq) {
2897                if (getView() != null && getView().isAttachedToWindow()) {
2898                    getView().post(() -> updateWithResponseUiThread(iq));
2899                } else {
2900                    pendingResponsePacket = iq;
2901                }
2902            }
2903
2904            protected void updateWithResponseUiThread(final IqPacket iq) {
2905                Timer oldTimer = this.loadingTimer;
2906                this.loadingTimer = new Timer();
2907                oldTimer.cancel();
2908                this.executing = false;
2909                this.loading = false;
2910                this.loadingHasBeenLong = false;
2911                this.responseElement = null;
2912                this.fillableFieldCount = 0;
2913                this.reported = null;
2914                this.response = iq;
2915                this.items.clear();
2916                this.actionsAdapter.clear();
2917                layoutManager.setSpanCount(1);
2918
2919                boolean actionsCleared = false;
2920                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2921                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2922                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2923                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2924                    }
2925
2926                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2927                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2928                    }
2929
2930                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2931                    if (actions != null) {
2932                        for (Element action : actions.getChildren()) {
2933                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2934                            if ("execute".equals(action.getName())) continue;
2935
2936                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2937                        }
2938                    }
2939
2940                    for (Element el : command.getChildren()) {
2941                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2942                            Data form = Data.parse(el);
2943                            String title = form.getTitle();
2944                            if (title != null) {
2945                                mTitle = title;
2946                                ConversationPagerAdapter.this.notifyDataSetChanged();
2947                            }
2948
2949                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2950                                this.responseElement = el;
2951                                setupReported(el.findChild("reported", "jabber:x:data"));
2952                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
2953                            }
2954
2955                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2956                            if (actionList != null) {
2957                                actionsAdapter.clear();
2958
2959                                for (Option action : actionList.getOptions()) {
2960                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2961                                }
2962                            }
2963
2964                            eu.siacs.conversations.xmpp.forms.Field fillableField = null;
2965                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2966                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2967                                   final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2968                                   final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2969                                    fillableField = range == null ? field : null;
2970                                    fillableFieldCount++;
2971                                }
2972                            }
2973
2974                            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))) {
2975                                actionsCleared = true;
2976                                actionsAdapter.clearProceed();
2977                            }
2978                            break;
2979                        }
2980                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2981                            String url = el.findChildContent("url", "jabber:x:oob");
2982                            if (url != null) {
2983                                String scheme = Uri.parse(url).getScheme();
2984                                if (scheme.equals("http") || scheme.equals("https")) {
2985                                    this.responseElement = el;
2986                                    break;
2987                                }
2988                                if (scheme.equals("xmpp")) {
2989                                    expectingRemoval = true;
2990                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2991                                    intent.setAction(Intent.ACTION_VIEW);
2992                                    intent.setData(Uri.parse(url));
2993                                    getView().getContext().startActivity(intent);
2994                                    break;
2995                                }
2996                            }
2997                        }
2998                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2999                            this.responseElement = el;
3000                            break;
3001                        }
3002                    }
3003
3004                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3005                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3006                            if (xmppConnectionService.isOnboarding()) {
3007                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3008                                    xmppConnectionService.deleteAccount(getAccount());
3009                                } else {
3010                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3011                                        removeSession(this);
3012                                        return;
3013                                    } else {
3014                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3015                                        xmppConnectionService.deleteAccount(getAccount());
3016                                    }
3017                                }
3018                            }
3019                            xmppConnectionService.archiveConversation(Conversation.this);
3020                        }
3021
3022                        expectingRemoval = true;
3023                        removeSession(this);
3024                        return;
3025                    }
3026
3027                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3028                        // No actions have been given, but we are not done?
3029                        // This is probably a spec violation, but we should do *something*
3030                        actionsAdapter.add(Pair.create("execute", "execute"));
3031                    }
3032
3033                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3034                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3035                            actionsAdapter.add(Pair.create("close", "close"));
3036                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3037                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3038                        }
3039                    }
3040                }
3041
3042                if (actionsAdapter.isEmpty()) {
3043                    actionsAdapter.add(Pair.create("close", "close"));
3044                }
3045
3046                actionsAdapter.sort((x, y) -> {
3047                    if (x.first.equals("cancel")) return -1;
3048                    if (y.first.equals("cancel")) return 1;
3049                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3050                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3051                    return 0;
3052                });
3053
3054                Data dataForm = null;
3055                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3056                if (mNode.equals("jabber:iq:register") &&
3057                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
3058                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3059
3060
3061                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3062                    execute();
3063                }
3064                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3065                notifyDataSetChanged();
3066            }
3067
3068            protected void setupReported(Element el) {
3069                if (el == null) {
3070                    reported = null;
3071                    return;
3072                }
3073
3074                reported = new ArrayList<>();
3075                for (Element fieldEl : el.getChildren()) {
3076                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3077                    reported.add(mkField(fieldEl));
3078                }
3079            }
3080
3081            @Override
3082            public int getItemCount() {
3083                if (loading) return 1;
3084                if (response == null) return 0;
3085                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3086                    int i = 0;
3087                    for (Element el : responseElement.getChildren()) {
3088                        if (!el.getNamespace().equals("jabber:x:data")) continue;
3089                        if (el.getName().equals("title")) continue;
3090                        if (el.getName().equals("field")) {
3091                            String type = el.getAttribute("type");
3092                            if (type != null && type.equals("hidden")) continue;
3093                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3094                        }
3095
3096                        if (el.getName().equals("reported") || el.getName().equals("item")) {
3097                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3098                                if (el.getName().equals("reported")) continue;
3099                                i += 1;
3100                            } else {
3101                                if (reported != null) i += reported.size();
3102                            }
3103                            continue;
3104                        }
3105
3106                        i++;
3107                    }
3108                    return i;
3109                }
3110                return 1;
3111            }
3112
3113            public Item getItem(int position) {
3114                if (loading) return new Item(null, TYPE_PROGRESSBAR);
3115                if (items.get(position) != null) return items.get(position);
3116                if (response == null) return null;
3117
3118                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3119                    if (responseElement.getNamespace().equals("jabber:x:data")) {
3120                        int i = 0;
3121                        for (Element el : responseElement.getChildren()) {
3122                            if (!el.getNamespace().equals("jabber:x:data")) continue;
3123                            if (el.getName().equals("title")) continue;
3124                            if (el.getName().equals("field")) {
3125                                String type = el.getAttribute("type");
3126                                if (type != null && type.equals("hidden")) continue;
3127                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3128                            }
3129
3130                            if (el.getName().equals("reported") || el.getName().equals("item")) {
3131                                Cell cell = null;
3132
3133                                if (reported != null) {
3134                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3135                                        if (el.getName().equals("reported")) continue;
3136                                        if (i == position) {
3137                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
3138                                            return items.get(position);
3139                                        }
3140                                    } else {
3141                                        if (reported.size() > position - i) {
3142                                            Field reportedField = reported.get(position - i);
3143                                            Element itemField = null;
3144                                            if (el.getName().equals("item")) {
3145                                                for (Element subel : el.getChildren()) {
3146                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
3147                                                       itemField = subel;
3148                                                       break;
3149                                                    }
3150                                                }
3151                                            }
3152                                            cell = new Cell(reportedField, itemField);
3153                                        } else {
3154                                            i += reported.size();
3155                                            continue;
3156                                        }
3157                                    }
3158                                }
3159
3160                                if (cell != null) {
3161                                    items.put(position, cell);
3162                                    return cell;
3163                                }
3164                            }
3165
3166                            if (i < position) {
3167                                i++;
3168                                continue;
3169                            }
3170
3171                            return mkItem(el, position);
3172                        }
3173                    }
3174                }
3175
3176                return mkItem(responseElement == null ? response : responseElement, position);
3177            }
3178
3179            @Override
3180            public int getItemViewType(int position) {
3181                return getItem(position).viewType;
3182            }
3183
3184            @Override
3185            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3186                switch(viewType) {
3187                    case TYPE_ERROR: {
3188                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3189                        return new ErrorViewHolder(binding);
3190                    }
3191                    case TYPE_NOTE: {
3192                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3193                        return new NoteViewHolder(binding);
3194                    }
3195                    case TYPE_WEB: {
3196                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3197                        return new WebViewHolder(binding);
3198                    }
3199                    case TYPE_RESULT_FIELD: {
3200                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3201                        return new ResultFieldViewHolder(binding);
3202                    }
3203                    case TYPE_RESULT_CELL: {
3204                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3205                        return new ResultCellViewHolder(binding);
3206                    }
3207                    case TYPE_ITEM_CARD: {
3208                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3209                        return new ItemCardViewHolder(binding);
3210                    }
3211                    case TYPE_CHECKBOX_FIELD: {
3212                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3213                        return new CheckboxFieldViewHolder(binding);
3214                    }
3215                    case TYPE_SEARCH_LIST_FIELD: {
3216                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3217                        return new SearchListFieldViewHolder(binding);
3218                    }
3219                    case TYPE_RADIO_EDIT_FIELD: {
3220                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3221                        return new RadioEditFieldViewHolder(binding);
3222                    }
3223                    case TYPE_SPINNER_FIELD: {
3224                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3225                        return new SpinnerFieldViewHolder(binding);
3226                    }
3227                    case TYPE_BUTTON_GRID_FIELD: {
3228                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3229                        return new ButtonGridFieldViewHolder(binding);
3230                    }
3231                    case TYPE_TEXT_FIELD: {
3232                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3233                        return new TextFieldViewHolder(binding);
3234                    }
3235                    case TYPE_SLIDER_FIELD: {
3236                        CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3237                        return new SliderFieldViewHolder(binding);
3238                    }
3239                    case TYPE_PROGRESSBAR: {
3240                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3241                        return new ProgressBarViewHolder(binding);
3242                    }
3243                    default:
3244                        if (expectingRemoval) {
3245                            CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3246                            return new NoteViewHolder(binding);
3247                        }
3248
3249                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3250                }
3251            }
3252
3253            @Override
3254            public void onBindViewHolder(ViewHolder viewHolder, int position) {
3255                viewHolder.bind(getItem(position));
3256            }
3257
3258            public View getView() {
3259                if (mBinding == null) return null;
3260                return mBinding.getRoot();
3261            }
3262
3263            public boolean validate() {
3264                int count = getItemCount();
3265                boolean isValid = true;
3266                for (int i = 0; i < count; i++) {
3267                    boolean oneIsValid = getItem(i).validate();
3268                    isValid = isValid && oneIsValid;
3269                }
3270                notifyDataSetChanged();
3271                return isValid;
3272            }
3273
3274            public boolean execute() {
3275                return execute("execute");
3276            }
3277
3278            public boolean execute(int actionPosition) {
3279                return execute(actionsAdapter.getItem(actionPosition).first);
3280            }
3281
3282            public synchronized boolean execute(String action) {
3283                if (!"cancel".equals(action) && executing) {
3284                    loadingHasBeenLong = true;
3285                    notifyDataSetChanged();
3286                    return false;
3287                }
3288                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3289
3290                if (response == null) return true;
3291                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3292                if (command == null) return true;
3293                String status = command.getAttribute("status");
3294                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3295
3296                if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3297                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3298                    return false;
3299                }
3300
3301                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3302                packet.setTo(response.getFrom());
3303                final Element c = packet.addChild("command", Namespace.COMMANDS);
3304                c.setAttribute("node", mNode);
3305                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3306
3307                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3308                if (!action.equals("cancel") &&
3309                    !action.equals("prev") &&
3310                    responseElement != null &&
3311                    responseElement.getName().equals("x") &&
3312                    responseElement.getNamespace().equals("jabber:x:data") &&
3313                    formType != null && formType.equals("form")) {
3314
3315                    Data form = Data.parse(responseElement);
3316                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3317                    if (actionList != null) {
3318                        actionList.setValue(action);
3319                        c.setAttribute("action", "execute");
3320                    }
3321
3322                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3323                        if (form.getValue("gateway-jid") == null) {
3324                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3325                        } else {
3326                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3327                        }
3328                    }
3329
3330                    responseElement.setAttribute("type", "submit");
3331                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3332                    if (rsm != null) {
3333                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3334                        max.setContent("1000");
3335                        rsm.addChild(max);
3336                    }
3337
3338                    c.addChild(responseElement);
3339                }
3340
3341                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3342
3343                executing = true;
3344                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3345                    updateWithResponse(iq);
3346                }, 120L);
3347
3348                loading();
3349                return false;
3350            }
3351
3352            public void refresh() {
3353                synchronized(this) {
3354                    if (waitingForRefresh) notifyDataSetChanged();
3355                }
3356            }
3357
3358            protected void loading() {
3359                View v = getView();
3360                try {
3361                    loadingTimer.schedule(new TimerTask() {
3362                        @Override
3363                        public void run() {
3364                            View v2 = getView();
3365                            loading = true;
3366
3367                            try {
3368                                loadingTimer.schedule(new TimerTask() {
3369                                    @Override
3370                                    public void run() {
3371                                        loadingHasBeenLong = true;
3372                                        if (v == null && v2 == null) return;
3373                                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3374                                    }
3375                                }, 3000);
3376                            } catch (final IllegalStateException e) { }
3377
3378                            if (v == null && v2 == null) return;
3379                            (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3380                        }
3381                    }, 500);
3382                } catch (final IllegalStateException e) { }
3383            }
3384
3385            protected GridLayoutManager setupLayoutManager(final Context ctx) {
3386                int spanCount = 1;
3387
3388                if (reported != null) {
3389                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3390                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3391                    float tableHeaderWidth = reported.stream().reduce(
3392                        0f,
3393                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3394                        (a, b) -> a + b
3395                    );
3396
3397                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3398                }
3399
3400                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3401                    items.clear();
3402                    notifyDataSetChanged();
3403                }
3404
3405                layoutManager = new GridLayoutManager(ctx, spanCount);
3406                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3407                    @Override
3408                    public int getSpanSize(int position) {
3409                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3410                        return 1;
3411                    }
3412                });
3413                return layoutManager;
3414            }
3415
3416            protected void setBinding(CommandPageBinding b) {
3417                mBinding = b;
3418                // https://stackoverflow.com/a/32350474/8611
3419                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3420                    @Override
3421                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3422                        if(rv.getChildCount() > 0) {
3423                            int[] location = new int[2];
3424                            rv.getLocationOnScreen(location);
3425                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3426                            if (childView instanceof ViewGroup) {
3427                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3428                            }
3429                            int action = e.getAction();
3430                            switch (action) {
3431                                case MotionEvent.ACTION_DOWN:
3432                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3433                                        rv.requestDisallowInterceptTouchEvent(true);
3434                                    }
3435                                case MotionEvent.ACTION_UP:
3436                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3437                                        rv.requestDisallowInterceptTouchEvent(true);
3438                                    }
3439                            }
3440                        }
3441
3442                        return false;
3443                    }
3444
3445                    @Override
3446                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3447
3448                    @Override
3449                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3450                });
3451                mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3452                mBinding.form.setAdapter(this);
3453
3454                if (actionsAdapter == null) {
3455                    actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3456                    actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3457                        @Override
3458                        public void onChanged() {
3459                            if (mBinding == null) return;
3460
3461                            mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3462                        }
3463
3464                        @Override
3465                        public void onInvalidated() {}
3466                    });
3467                }
3468
3469                mBinding.actions.setAdapter(actionsAdapter);
3470                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3471                    if (execute(pos)) {
3472                        removeSession(CommandSession.this);
3473                    }
3474                });
3475
3476                actionsAdapter.notifyDataSetChanged();
3477
3478                if (pendingResponsePacket != null) {
3479                    final IqPacket pending = pendingResponsePacket;
3480                    pendingResponsePacket = null;
3481                    updateWithResponseUiThread(pending);
3482                }
3483            }
3484
3485            private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3486               if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image"))  {
3487                   return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3488               } else {
3489                   return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3490               }
3491            }
3492
3493            private Drawable getDrawableForUrl(final String url) {
3494                final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3495                final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3496                final Drawable d = cache.get(url);
3497                if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3498                if (d == null) {
3499                    synchronized (CommandSession.this) {
3500                        waitingForRefresh = true;
3501                    }
3502                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3503                    Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3504                    dummy.setStatus(Message.STATUS_DUMMY);
3505                    dummy.setFileParams(new Message.FileParams(url));
3506                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3507                        if (file == null) {
3508                            dummy.getTransferable().start();
3509                        } else {
3510                            try {
3511                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3512                            } catch (final Exception e) { }
3513                        }
3514                    });
3515                }
3516                return d;
3517            }
3518
3519            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3520                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3521                setBinding(binding);
3522                return binding.getRoot();
3523            }
3524
3525            // https://stackoverflow.com/a/36037991/8611
3526            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3527                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3528                    View child = viewGroup.getChildAt(i);
3529                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3530                        View foundView = findViewAt((ViewGroup) child, x, y);
3531                        if (foundView != null && foundView.isShown()) {
3532                            return foundView;
3533                        }
3534                    } else {
3535                        int[] location = new int[2];
3536                        child.getLocationOnScreen(location);
3537                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3538                        if (rect.contains((int)x, (int)y)) {
3539                            return child;
3540                        }
3541                    }
3542                }
3543
3544                return null;
3545            }
3546        }
3547
3548        class MucConfigSession extends CommandSession {
3549            MucConfigSession(XmppConnectionService xmppConnectionService) {
3550                super("Configure Channel", null, xmppConnectionService);
3551            }
3552
3553            @Override
3554            protected void updateWithResponseUiThread(final IqPacket iq) {
3555                Timer oldTimer = this.loadingTimer;
3556                this.loadingTimer = new Timer();
3557                oldTimer.cancel();
3558                this.executing = false;
3559                this.loading = false;
3560                this.loadingHasBeenLong = false;
3561                this.responseElement = null;
3562                this.fillableFieldCount = 0;
3563                this.reported = null;
3564                this.response = iq;
3565                this.items.clear();
3566                this.actionsAdapter.clear();
3567                layoutManager.setSpanCount(1);
3568
3569                final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3570                if (iq.getType() == IqPacket.TYPE.RESULT && query != null) {
3571                    final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3572                    final String title = form.getTitle();
3573                    if (title != null) {
3574                        mTitle = title;
3575                        ConversationPagerAdapter.this.notifyDataSetChanged();
3576                    }
3577
3578                    this.responseElement = form;
3579                    setupReported(form.findChild("reported", "jabber:x:data"));
3580                    if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3581
3582                    if (actionsAdapter.countExceptCancel() < 1) {
3583                        actionsAdapter.add(Pair.create("save", "Save"));
3584                    }
3585
3586                    if (actionsAdapter.getPosition("cancel") < 0) {
3587                        actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3588                    }
3589                } else if (iq.getType() == IqPacket.TYPE.RESULT) {
3590                    expectingRemoval = true;
3591                    removeSession(this);
3592                    return;
3593                } else {
3594                    actionsAdapter.add(Pair.create("close", "close"));
3595                }
3596
3597                notifyDataSetChanged();
3598            }
3599
3600            @Override
3601            public synchronized boolean execute(String action) {
3602                if ("cancel".equals(action)) {
3603                    final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3604                    packet.setTo(response.getFrom());
3605                    final Element form = packet
3606                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3607                        .addChild("x", "jabber:x:data");
3608                    form.setAttribute("type", "cancel");
3609                    xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3610                    return true;
3611                }
3612
3613                if (!"save".equals(action)) return true;
3614
3615                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3616                packet.setTo(response.getFrom());
3617
3618                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3619                if (responseElement != null &&
3620                    responseElement.getName().equals("x") &&
3621                    responseElement.getNamespace().equals("jabber:x:data") &&
3622                    formType != null && formType.equals("form")) {
3623
3624                    responseElement.setAttribute("type", "submit");
3625                    packet
3626                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3627                        .addChild(responseElement);
3628                }
3629
3630                executing = true;
3631                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3632                    updateWithResponse(iq);
3633                }, 120L);
3634
3635                loading();
3636
3637                return false;
3638            }
3639        }
3640    }
3641
3642    public static class Thread {
3643        protected Message subject = null;
3644        protected Message first = null;
3645        protected Message last = null;
3646        protected final String threadId;
3647
3648        protected Thread(final String threadId) {
3649            this.threadId = threadId;
3650        }
3651
3652        public String getThreadId() {
3653            return threadId;
3654        }
3655
3656        public String getSubject() {
3657            if (subject == null) return null;
3658
3659            return subject.getSubject();
3660        }
3661
3662        public String getDisplay() {
3663            final String s = getSubject();
3664            if (s != null) return s;
3665
3666            if (first != null) {
3667                return first.getBody();
3668            }
3669
3670            return "";
3671        }
3672
3673        public long getLastTime() {
3674            if (last == null) return 0;
3675
3676            return last.getTimeSent();
3677        }
3678    }
3679}