Conversation.java

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