Conversation.java

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