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