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