Conversation.java

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