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