Conversation.java

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