Conversation.java

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