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