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