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