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