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