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