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