Conversation.java

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