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