Conversation.java

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