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