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