Conversation.java

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