Conversation.java

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