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