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