Conversation.java

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