Conversation.java

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