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            if (sessions == null) return;
2061            if (!sessions.remove(session)) return;
2062            session.close();
2063            notifyDataSetChanged();
2064            if (session instanceof WebxdcPage && mPager.get() != null) mPager.get().setCurrentItem(0);
2065        }
2066
2067        public boolean switchToSession(final String node) {
2068            if (sessions == null) return false;
2069
2070            int i = 0;
2071            for (ConversationPage session : sessions) {
2072                if (session.getNode().equals(node)) {
2073                    if (mPager.get() != null) mPager.get().setCurrentItem(i + 2);
2074                    return true;
2075                }
2076                i++;
2077            }
2078
2079            return false;
2080        }
2081
2082        @NonNull
2083        @Override
2084        public Object instantiateItem(@NonNull ViewGroup container, int position) {
2085            if (position == 0) {
2086                final var pg1 = page1.get();
2087                if (pg1 != null && pg1.getParent() != null) {
2088                    ((ViewGroup) pg1.getParent()).removeView(pg1);
2089                }
2090                container.addView(pg1);
2091                return pg1;
2092            }
2093            if (position == 1) {
2094                final var pg2 = page2.get();
2095                if (pg2 != null && pg2.getParent() != null) {
2096                    ((ViewGroup) pg2.getParent()).removeView(pg2);
2097                }
2098                container.addView(pg2);
2099                return pg2;
2100            }
2101
2102            if (position-2 >= sessions.size()) return null;
2103            ConversationPage session = sessions.get(position-2);
2104            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
2105            if (v != null && v.getParent() != null) {
2106                ((ViewGroup) v.getParent()).removeView(v);
2107            }
2108            container.addView(v);
2109            return session;
2110        }
2111
2112        @Override
2113        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
2114            if (o == null) return;
2115            if (position < 2) {
2116                container.removeView((View) o);
2117                return;
2118            }
2119
2120            container.removeView(((ConversationPage) o).getView());
2121        }
2122
2123        @Override
2124        public int getItemPosition(Object o) {
2125            if (mPager.get() != null) {
2126                final var page1Deref = page1.get();
2127                if (o == page1Deref) {
2128                    if (page1Deref == null) return PagerAdapter.POSITION_NONE;
2129                    else return PagerAdapter.POSITION_UNCHANGED;
2130                }
2131                final var page2Deref = page2.get();
2132                if (o == page2Deref) {
2133                    if (page2Deref == null) return PagerAdapter.POSITION_NONE;
2134                    else return PagerAdapter.POSITION_UNCHANGED;
2135                }
2136            }
2137
2138            int pos = sessions == null ? -1 : sessions.indexOf(o);
2139            if (pos < 0) return PagerAdapter.POSITION_NONE;
2140            return pos + 2;
2141        }
2142
2143        @Override
2144        public int getCount() {
2145            if (sessions == null) return 1;
2146
2147            int count = 2 + sessions.size();
2148            if (mTabs.get() == null) return count;
2149
2150            if (count > 2) {
2151                mTabs.get().setTabMode(TabLayout.MODE_SCROLLABLE);
2152            } else {
2153                mTabs.get().setTabMode(TabLayout.MODE_FIXED);
2154            }
2155            return count;
2156        }
2157
2158        @Override
2159        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
2160            if (view == o) return true;
2161
2162            if (o instanceof ConversationPage) {
2163                return ((ConversationPage) o).getView() == view;
2164            }
2165
2166            return false;
2167        }
2168
2169        @Nullable
2170        @Override
2171        public CharSequence getPageTitle(int position) {
2172            switch (position) {
2173                case 0:
2174                    return "Conversation";
2175                case 1:
2176                    return "Commands";
2177                default:
2178                    ConversationPage session = sessions.get(position-2);
2179                    if (session == null) return super.getPageTitle(position);
2180                    return session.getTitle();
2181            }
2182        }
2183
2184        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
2185            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
2186                protected T binding;
2187
2188                public ViewHolder(T binding) {
2189                    super(binding.getRoot());
2190                    this.binding = binding;
2191                }
2192
2193                abstract public void bind(Item el);
2194
2195                protected void setTextOrHide(TextView v, Optional<String> s) {
2196                    if (s == null || !s.isPresent()) {
2197                        v.setVisibility(View.GONE);
2198                    } else {
2199                        v.setVisibility(View.VISIBLE);
2200                        v.setText(s.get());
2201                    }
2202                }
2203
2204                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
2205                    int flags = 0;
2206                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
2207                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
2208
2209                    String type = field.getAttribute("type");
2210                    if (type != null) {
2211                        if (type.equals("text-multi") || type.equals("jid-multi")) {
2212                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
2213                        }
2214
2215                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
2216
2217                        if (type.equals("jid-single") || type.equals("jid-multi")) {
2218                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2219                        }
2220
2221                        if (type.equals("text-private")) {
2222                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
2223                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
2224                        }
2225                    }
2226
2227                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2228                    if (validate == null) return;
2229                    String datatype = validate.getAttribute("datatype");
2230                    if (datatype == null) return;
2231
2232                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
2233                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
2234                    }
2235
2236                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
2237                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
2238                    }
2239
2240                    if (datatype.equals("xs:date")) {
2241                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
2242                    }
2243
2244                    if (datatype.equals("xs:dateTime")) {
2245                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
2246                    }
2247
2248                    if (datatype.equals("xs:time")) {
2249                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
2250                    }
2251
2252                    if (datatype.equals("xs:anyURI")) {
2253                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
2254                    }
2255
2256                    if (datatype.equals("html:tel")) {
2257                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
2258                    }
2259
2260                    if (datatype.equals("html:email")) {
2261                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
2262                    }
2263                }
2264
2265                protected String formatValue(String datatype, String value, boolean compact) {
2266                    if ("xs:dateTime".equals(datatype)) {
2267                        ZonedDateTime zonedDateTime = null;
2268                        try {
2269                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
2270                        } catch (final DateTimeParseException e) {
2271                            try {
2272                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
2273                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
2274                            } catch (final DateTimeParseException e2) { }
2275                        }
2276                        if (zonedDateTime == null) return value;
2277                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
2278                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
2279                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
2280                    }
2281
2282                    if ("html:tel".equals(datatype) && !compact) {
2283                        return PhoneNumberUtils.formatNumber(value, value, null);
2284                    }
2285
2286                    return value;
2287                }
2288            }
2289
2290            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
2291                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
2292
2293                @Override
2294                public void bind(Item iq) {
2295                    binding.errorIcon.setVisibility(View.VISIBLE);
2296
2297                    if (iq == null || iq.el == null) return;
2298                    Element error = iq.el.findChild("error");
2299                    if (error == null) {
2300                        binding.message.setText("Unexpected response: " + iq);
2301                        return;
2302                    }
2303                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
2304                    if (text == null || text.equals("")) {
2305                        text = error.getChildren().get(0).getName();
2306                    }
2307                    binding.message.setText(text);
2308                }
2309            }
2310
2311            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
2312                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
2313
2314                @Override
2315                public void bind(Item note) {
2316                    binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
2317
2318                    String type = note.el.getAttribute("type");
2319                    if (type != null && type.equals("error")) {
2320                        binding.errorIcon.setVisibility(View.VISIBLE);
2321                    }
2322                }
2323            }
2324
2325            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
2326                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
2327
2328                @Override
2329                public void bind(Item item) {
2330                    Field field = (Field) item;
2331                    setTextOrHide(binding.label, field.getLabel());
2332                    setTextOrHide(binding.desc, field.getDesc());
2333
2334                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
2335                    if (media == null) {
2336                        binding.mediaImage.setVisibility(View.GONE);
2337                    } else {
2338                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
2339                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
2340                        for (Element uriEl : media.getChildren()) {
2341                            if (!"uri".equals(uriEl.getName())) continue;
2342                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
2343                            String mimeType = uriEl.getAttribute("type");
2344                            String uriS = uriEl.getContent();
2345                            if (mimeType == null || uriS == null) continue;
2346                            Uri uri = Uri.parse(uriS);
2347                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
2348                                final Drawable d = getDrawableForUrl(uri.toString());
2349                                if (d != null) {
2350                                    binding.mediaImage.setImageDrawable(d);
2351                                    binding.mediaImage.setVisibility(View.VISIBLE);
2352                                }
2353                            }
2354                        }
2355                    }
2356
2357                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2358                    String datatype = validate == null ? null : validate.getAttribute("datatype");
2359
2360                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
2361                    for (Element el : field.el.getChildren()) {
2362                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
2363                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
2364                        }
2365                    }
2366                    binding.values.setAdapter(values);
2367                    Util.justifyListViewHeightBasedOnChildren(binding.values);
2368
2369                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
2370                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2371                            final var jid = Uri.encode(Jid.of(values.getItem(pos).getValue()).toString(), "@/+");
2372                            new FixedURLSpan("xmpp:" + jid, account).onClick(binding.values);
2373                        });
2374                    } else if ("xs:anyURI".equals(datatype)) {
2375                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2376                            new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2377                        });
2378                    } else if ("html:tel".equals(datatype)) {
2379                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2380                            try {
2381                                new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2382                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2383                        });
2384                    }
2385
2386                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2387                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2388                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2389                        }
2390                        return true;
2391                    });
2392                }
2393            }
2394
2395            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2396                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2397
2398                @Override
2399                public void bind(Item item) {
2400                    Cell cell = (Cell) item;
2401
2402                    if (cell.el == null) {
2403                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2404                        setTextOrHide(binding.text, cell.reported.getLabel());
2405                    } else {
2406                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2407                        String datatype = validate == null ? null : validate.getAttribute("datatype");
2408                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2409                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2410                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2411                            final var jid = Uri.encode(Jid.of(text.toString()).toString(), "@/+");
2412                            text.setSpan(new FixedURLSpan("xmpp:" + jid, account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2413                        } else if ("xs:anyURI".equals(datatype)) {
2414                            text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2415                        } else if ("html:tel".equals(datatype)) {
2416                            try {
2417                                text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2418                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2419                        }
2420
2421                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2422                        binding.text.setText(text);
2423
2424                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2425                        method.setOnLinkLongClickListener((tv, url) -> {
2426                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2427                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2428                            return true;
2429                        });
2430                        binding.text.setMovementMethod(method);
2431                    }
2432                }
2433            }
2434
2435            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2436                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2437
2438                @Override
2439                public void bind(Item item) {
2440                    binding.fields.removeAllViews();
2441
2442                    for (Field field : reported) {
2443                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2444                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2445                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2446                        param.width = 0;
2447                        row.getRoot().setLayoutParams(param);
2448                        binding.fields.addView(row.getRoot());
2449                        for (Element el : item.el.getChildren()) {
2450                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2451                                for (String label : field.getLabel().asSet()) {
2452                                    el.setAttribute("label", label);
2453                                }
2454                                for (String desc : field.getDesc().asSet()) {
2455                                    el.setAttribute("desc", desc);
2456                                }
2457                                for (String type : field.getType().asSet()) {
2458                                    el.setAttribute("type", type);
2459                                }
2460                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2461                                if (validate != null) el.addChild(validate);
2462                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2463                            }
2464                        }
2465                    }
2466                }
2467            }
2468
2469            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2470                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2471                    super(binding);
2472                    binding.row.setOnClickListener((v) -> {
2473                        binding.checkbox.toggle();
2474                    });
2475                    binding.checkbox.setOnCheckedChangeListener(this);
2476                }
2477                protected Element mValue = null;
2478
2479                @Override
2480                public void bind(Item item) {
2481                    Field field = (Field) item;
2482                    binding.label.setText(field.getLabel().or(""));
2483                    setTextOrHide(binding.desc, field.getDesc());
2484                    mValue = field.getValue();
2485                    final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2486                    mValue.setContent(isChecked ? "true" : "false");
2487                    binding.checkbox.setChecked(isChecked);
2488                }
2489
2490                @Override
2491                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2492                    if (mValue == null) return;
2493
2494                    mValue.setContent(isChecked ? "true" : "false");
2495                }
2496            }
2497
2498            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2499                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2500                    super(binding);
2501                    binding.search.addTextChangedListener(this);
2502                }
2503                protected Field field = null;
2504                Set<String> filteredValues;
2505                List<Option> options = new ArrayList<>();
2506                protected ArrayAdapter<Option> adapter;
2507                protected boolean open;
2508                protected boolean multi;
2509                protected int textColor = -1;
2510
2511                @Override
2512                public void bind(Item item) {
2513                    ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2514                    final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2515                    if (fillableFieldCount > 1) {
2516                        layout.height = (int) (density * 200);
2517                    } else {
2518                        layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2519                    }
2520                    binding.list.setLayoutParams(layout);
2521
2522                    field = (Field) item;
2523                    setTextOrHide(binding.label, field.getLabel());
2524                    setTextOrHide(binding.desc, field.getDesc());
2525
2526                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2527                    if (field.error != null) {
2528                        binding.desc.setVisibility(View.VISIBLE);
2529                        binding.desc.setText(field.error);
2530                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2531                    } else {
2532                        binding.desc.setTextColor(textColor);
2533                    }
2534
2535                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2536                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2537                    setupInputType(field.el, binding.search, null);
2538
2539                    multi = field.getType().equals(Optional.of("list-multi"));
2540                    if (multi) {
2541                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2542                    } else {
2543                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2544                    }
2545
2546                    options = field.getOptions();
2547                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
2548                        Set<String> values = new HashSet<>();
2549                        if (multi) {
2550                            final var optionValues = options.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2551                            values.addAll(field.getValues());
2552                            for (final String value : field.getValues()) {
2553                                if (filteredValues.contains(value) || (!open && !optionValues.contains(value))) {
2554                                    values.remove(value);
2555                                }
2556                            }
2557                        }
2558
2559                        SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2560                        for (int i = 0; i < positions.size(); i++) {
2561                            if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2562                        }
2563                        field.setValues(values);
2564
2565                        if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2566                    });
2567                    search("");
2568                }
2569
2570                @Override
2571                public void afterTextChanged(Editable s) {
2572                    if (!multi && open) field.setValues(List.of(s.toString()));
2573                    search(s.toString());
2574                }
2575
2576                @Override
2577                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2578
2579                @Override
2580                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2581
2582                protected void search(String s) {
2583                    List<Option> filteredOptions;
2584                    final String q = s.replaceAll("\\W", "").toLowerCase();
2585                    if (q == null || q.equals("")) {
2586                        filteredOptions = options;
2587                    } else {
2588                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2589                    }
2590                    filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2591                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2592                    binding.list.setAdapter(adapter);
2593
2594                    for (final String value : field.getValues()) {
2595                        int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2596                        if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2597                    }
2598                }
2599            }
2600
2601            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2602                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2603                    super(binding);
2604                    binding.open.addTextChangedListener(this);
2605                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2606                        @Override
2607                        public View getView(int position, View convertView, ViewGroup parent) {
2608                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2609                            v.setId(position);
2610                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2611                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2612                            return v;
2613                        }
2614                    };
2615                }
2616                protected Element mValue = null;
2617                protected ArrayAdapter<Option> options;
2618                protected int textColor = -1;
2619
2620                @Override
2621                public void bind(Item item) {
2622                    Field field = (Field) item;
2623                    setTextOrHide(binding.label, field.getLabel());
2624                    setTextOrHide(binding.desc, field.getDesc());
2625
2626                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2627                    if (field.error != null) {
2628                        binding.desc.setVisibility(View.VISIBLE);
2629                        binding.desc.setText(field.error);
2630                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2631                    } else {
2632                        binding.desc.setTextColor(textColor);
2633                    }
2634
2635                    mValue = field.getValue();
2636
2637                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2638                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2639                    binding.open.setText(mValue.getContent());
2640                    setupInputType(field.el, binding.open, null);
2641
2642                    options.clear();
2643                    List<Option> theOptions = field.getOptions();
2644                    options.addAll(theOptions);
2645
2646                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2647                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2648                    float maxColumnWidth = theOptions.stream().map((x) ->
2649                        StaticLayout.getDesiredWidth(x.toString(), paint)
2650                    ).max(Float::compare).orElse(new Float(0.0));
2651                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2652                        binding.radios.setNumColumns(theOptions.size());
2653                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2654                        binding.radios.setNumColumns(theOptions.size() / 2);
2655                    } else {
2656                        binding.radios.setNumColumns(1);
2657                    }
2658                    binding.radios.setAdapter(options);
2659                }
2660
2661                @Override
2662                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2663                    if (mValue == null) return;
2664
2665                    if (isChecked) {
2666                        mValue.setContent(options.getItem(radio.getId()).getValue());
2667                        binding.open.setText(mValue.getContent());
2668                    }
2669                    options.notifyDataSetChanged();
2670                }
2671
2672                @Override
2673                public void afterTextChanged(Editable s) {
2674                    if (mValue == null) return;
2675
2676                    mValue.setContent(s.toString());
2677                    options.notifyDataSetChanged();
2678                }
2679
2680                @Override
2681                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2682
2683                @Override
2684                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2685            }
2686
2687            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2688                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2689                    super(binding);
2690                    binding.spinner.setOnItemSelectedListener(this);
2691                }
2692                protected Element mValue = null;
2693
2694                @Override
2695                public void bind(Item item) {
2696                    Field field = (Field) item;
2697                    setTextOrHide(binding.label, field.getLabel());
2698                    binding.spinner.setPrompt(field.getLabel().or(""));
2699                    setTextOrHide(binding.desc, field.getDesc());
2700
2701                    mValue = field.getValue();
2702
2703                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2704                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2705                    options.addAll(field.getOptions());
2706
2707                    binding.spinner.setAdapter(options);
2708                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2709                }
2710
2711                @Override
2712                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2713                    Option o = (Option) parent.getItemAtPosition(pos);
2714                    if (mValue == null) return;
2715
2716                    mValue.setContent(o == null ? "" : o.getValue());
2717                }
2718
2719                @Override
2720                public void onNothingSelected(AdapterView<?> parent) {
2721                    mValue.setContent("");
2722                }
2723            }
2724
2725            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2726                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2727                    super(binding);
2728                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2729                        protected int height = 0;
2730
2731                        @Override
2732                        public View getView(int position, View convertView, ViewGroup parent) {
2733                            Button v = (Button) super.getView(position, convertView, parent);
2734                            v.setOnClickListener((view) -> {
2735                                mValue.setContent(getItem(position).getValue());
2736                                execute();
2737                                loading = true;
2738                            });
2739
2740                            final SVG icon = getItem(position).getIcon();
2741                            if (icon != null) {
2742                                 final Element iconEl = getItem(position).getIconEl();
2743                                 if (height < 1) {
2744                                     v.measure(0, 0);
2745                                     height = v.getMeasuredHeight();
2746                                 }
2747                                 if (height < 1) return v;
2748                                 if (mediaSelector) {
2749                                     final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2750                                     if (d != null) {
2751                                         final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2752                                         d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2753                                     }
2754                                     v.setCompoundDrawables(null, d, null, null);
2755                                 } else {
2756                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2757                                 }
2758                            }
2759
2760                            return v;
2761                        }
2762                    };
2763                }
2764                protected Element mValue = null;
2765                protected ArrayAdapter<Option> options;
2766                protected Option defaultOption = null;
2767                protected boolean mediaSelector = false;
2768                protected int textColor = -1;
2769
2770                @Override
2771                public void bind(Item item) {
2772                    Field field = (Field) item;
2773                    setTextOrHide(binding.label, field.getLabel());
2774                    setTextOrHide(binding.desc, field.getDesc());
2775
2776                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2777                    if (field.error != null) {
2778                        binding.desc.setVisibility(View.VISIBLE);
2779                        binding.desc.setText(field.error);
2780                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2781                    } else {
2782                        binding.desc.setTextColor(textColor);
2783                    }
2784
2785                    mValue = field.getValue();
2786                    mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2787
2788                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2789                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2790                    binding.openButton.setOnClickListener((view) -> {
2791                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2792                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2793                        builder.setPositiveButton(R.string.action_execute, null);
2794                        if (field.getDesc().isPresent()) {
2795                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2796                        }
2797                        dialogBinding.inputEditText.requestFocus();
2798                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2799                        builder.setView(dialogBinding.getRoot());
2800                        builder.setNegativeButton(R.string.cancel, null);
2801                        final AlertDialog dialog = builder.create();
2802                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2803                        dialog.show();
2804                        View.OnClickListener clickListener = v -> {
2805                            String value = dialogBinding.inputEditText.getText().toString();
2806                            mValue.setContent(value);
2807                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2808                            dialog.dismiss();
2809                            execute();
2810                            loading = true;
2811                        };
2812                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2813                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2814                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2815                            dialog.dismiss();
2816                        }));
2817                        dialog.setCanceledOnTouchOutside(false);
2818                        dialog.setOnDismissListener(dialog1 -> {
2819                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2820                        });
2821                    });
2822
2823                    options.clear();
2824                    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();
2825
2826                    defaultOption = null;
2827                    for (Option option : theOptions) {
2828                        if (option.getValue().equals(mValue.getContent())) {
2829                            defaultOption = option;
2830                            break;
2831                        }
2832                    }
2833                    if (defaultOption == null && !mValue.getContent().equals("")) {
2834                        // Synthesize default option for custom value
2835                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2836                    }
2837                    if (defaultOption == null) {
2838                        binding.defaultButton.setVisibility(View.GONE);
2839                        binding.defaultButtonSeperator.setVisibility(View.GONE);
2840                    } else {
2841                        theOptions.remove(defaultOption);
2842                        binding.defaultButton.setVisibility(View.VISIBLE);
2843                        binding.defaultButtonSeperator.setVisibility(View.VISIBLE);
2844
2845                        final SVG defaultIcon = defaultOption.getIcon();
2846                        if (defaultIcon != null) {
2847                             DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2848                             int height = (int)(display.heightPixels*display.density/8);
2849                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2850                        }
2851
2852                        binding.defaultButton.setText(defaultOption.toString());
2853                        binding.defaultButton.setOnClickListener((view) -> {
2854                            mValue.setContent(defaultOption.getValue());
2855                            execute();
2856                            loading = true;
2857                        });
2858                    }
2859
2860                    options.addAll(theOptions);
2861                    binding.buttons.setAdapter(options);
2862                }
2863            }
2864
2865            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2866                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2867                    super(binding);
2868                    binding.textinput.addTextChangedListener(this);
2869                }
2870                protected Field field = null;
2871
2872                @Override
2873                public void bind(Item item) {
2874                    field = (Field) item;
2875                    binding.textinputLayout.setHint(field.getLabel().or(""));
2876
2877                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2878                    for (String desc : field.getDesc().asSet()) {
2879                        binding.textinputLayout.setHelperText(desc);
2880                    }
2881
2882                    binding.textinputLayout.setErrorEnabled(field.error != null);
2883                    if (field.error != null) binding.textinputLayout.setError(field.error);
2884
2885                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2886                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2887                    if (suffixLabel == null) {
2888                        binding.textinputLayout.setSuffixText("");
2889                    } else {
2890                        binding.textinputLayout.setSuffixText(suffixLabel);
2891                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2892                    }
2893
2894                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2895                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2896
2897                    binding.textinput.setText(String.join("\n", field.getValues()));
2898                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2899                }
2900
2901                @Override
2902                public void afterTextChanged(Editable s) {
2903                    if (field == null) return;
2904
2905                    field.setValues(List.of(s.toString().split("\n")));
2906                }
2907
2908                @Override
2909                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2910
2911                @Override
2912                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2913            }
2914
2915            class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2916                public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2917                protected Field field = null;
2918
2919                @Override
2920                public void bind(Item item) {
2921                    field = (Field) item;
2922                    setTextOrHide(binding.label, field.getLabel());
2923                    setTextOrHide(binding.desc, field.getDesc());
2924                    final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2925                    final String datatype = validate == null ? null : validate.getAttribute("datatype");
2926                    final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2927                    // NOTE: range also implies open, so we don't have to be bound by the options strictly
2928                    // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2929                    Float min = null;
2930                    try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2931                    Float max = null;
2932                    try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max"));  } catch (NumberFormatException e) { }
2933
2934                    List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2935                    Collections.sort(options);
2936                    if (options.size() > 0) {
2937                        // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2938                        if (min == null) min = options.get(0);
2939                        if (max == null) max = options.get(options.size()-1);
2940                    }
2941
2942                    if (field.getValues().size() > 0) {
2943                        final var val = Float.valueOf(field.getValue().getContent());
2944                        if ((min == null || val >= min) && (max == null || val <= max)) {
2945                            binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2946                        } else {
2947                            binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2948                        }
2949                    } else {
2950                        binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2951                    }
2952                    binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2953                    binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2954                    if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2955                        binding.slider.setStepSize(1);
2956                    } else {
2957                        binding.slider.setStepSize(0);
2958                    }
2959
2960                    if (options.size() > 0) {
2961                        float step = -1;
2962                        Float prev = null;
2963                        for (final Float option : options) {
2964                            if (prev != null) {
2965                                float nextStep = option - prev;
2966                                if (step > 0 && step != nextStep) {
2967                                    step = -1;
2968                                    break;
2969                                }
2970                                step = nextStep;
2971                            }
2972                            prev = option;
2973                        }
2974                        if (step > 0) binding.slider.setStepSize(step);
2975                    }
2976
2977                    binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2978                        field.setValues(List.of(new DecimalFormat().format(value)));
2979                    });
2980                }
2981            }
2982
2983            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2984                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2985                protected String boundUrl = "";
2986
2987                @Override
2988                public void bind(Item oob) {
2989                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2990                    binding.webview.getSettings().setJavaScriptEnabled(true);
2991                    binding.webview.getSettings().setMediaPlaybackRequiresUserGesture(false);
2992                    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");
2993                    binding.webview.getSettings().setDatabaseEnabled(true);
2994                    binding.webview.getSettings().setDomStorageEnabled(true);
2995                    binding.webview.setWebChromeClient(new WebChromeClient() {
2996                        @Override
2997                        public void onProgressChanged(WebView view, int newProgress) {
2998                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2999                            binding.progressbar.setProgress(newProgress);
3000                        }
3001
3002                        @Override
3003                        public void onPermissionRequest(final PermissionRequest request) {
3004                            getView().post(() -> {
3005                                request.grant(request.getResources());
3006                            });
3007                        }
3008                    });
3009                    binding.webview.setWebViewClient(new WebViewClient() {
3010                        @Override
3011                        public void onPageFinished(WebView view, String url) {
3012                            super.onPageFinished(view, url);
3013                            mTitle = view.getTitle();
3014                            ConversationPagerAdapter.this.notifyDataSetChanged();
3015                        }
3016                    });
3017                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
3018                    if (!boundUrl.equals(url)) {
3019                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
3020                        binding.webview.loadUrl(url);
3021                        boundUrl = url;
3022                    }
3023                }
3024
3025                class JsObject {
3026                    @JavascriptInterface
3027                    public void execute() { execute("execute"); }
3028
3029                    @JavascriptInterface
3030                    public void execute(String action) {
3031                        getView().post(() -> {
3032                            actionToWebview = null;
3033                            if(CommandSession.this.execute(action)) {
3034                                removeSession(CommandSession.this);
3035                            }
3036                        });
3037                    }
3038
3039                    @JavascriptInterface
3040                    public void preventDefault() {
3041                        actionToWebview = binding.webview;
3042                    }
3043                }
3044            }
3045
3046            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
3047                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
3048
3049                @Override
3050                public void bind(Item item) {
3051                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
3052                }
3053            }
3054
3055            class Item {
3056                protected Element el;
3057                protected int viewType;
3058                protected String error = null;
3059
3060                Item(Element el, int viewType) {
3061                    this.el = el;
3062                    this.viewType = viewType;
3063                }
3064
3065                public boolean validate() {
3066                    error = null;
3067                    return true;
3068                }
3069            }
3070
3071            class Field extends Item {
3072                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
3073
3074                @Override
3075                public boolean validate() {
3076                    if (!super.validate()) return false;
3077                    if (el.findChild("required", "jabber:x:data") == null) return true;
3078                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
3079
3080                    error = "this value is required";
3081                    return false;
3082                }
3083
3084                public String getVar() {
3085                    return el.getAttribute("var");
3086                }
3087
3088                public Optional<String> getType() {
3089                    return Optional.fromNullable(el.getAttribute("type"));
3090                }
3091
3092                public Optional<String> getLabel() {
3093                    String label = el.getAttribute("label");
3094                    if (label == null) label = getVar();
3095                    return Optional.fromNullable(label);
3096                }
3097
3098                public Optional<String> getDesc() {
3099                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
3100                }
3101
3102                public Element getValue() {
3103                    Element value = el.findChild("value", "jabber:x:data");
3104                    if (value == null) {
3105                        value = el.addChild("value", "jabber:x:data");
3106                    }
3107                    return value;
3108                }
3109
3110                public void setValues(Collection<String> values) {
3111                    for(Element child : el.getChildren()) {
3112                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
3113                            el.removeChild(child);
3114                        }
3115                    }
3116
3117                    for (String value : values) {
3118                        el.addChild("value", "jabber:x:data").setContent(value);
3119                    }
3120                }
3121
3122                public List<String> getValues() {
3123                    List<String> values = new ArrayList<>();
3124                    for(Element child : el.getChildren()) {
3125                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
3126                            values.add(child.getContent());
3127                        }
3128                    }
3129                    return values;
3130                }
3131
3132                public List<Option> getOptions() {
3133                    return Option.forField(el);
3134                }
3135            }
3136
3137            class Cell extends Item {
3138                protected Field reported;
3139
3140                Cell(Field reported, Element item) {
3141                    super(item, TYPE_RESULT_CELL);
3142                    this.reported = reported;
3143                }
3144            }
3145
3146            protected Field mkField(Element el) {
3147                int viewType = -1;
3148
3149                String formType = responseElement.getAttribute("type");
3150                if (formType != null) {
3151                    String fieldType = el.getAttribute("type");
3152                    if (fieldType == null) fieldType = "text-single";
3153
3154                    if (formType.equals("result") || fieldType.equals("fixed")) {
3155                        viewType = TYPE_RESULT_FIELD;
3156                    } else if (formType.equals("form")) {
3157                        final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3158                        final String datatype = validate == null ? null : validate.getAttribute("datatype");
3159                        final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3160                        if (fieldType.equals("boolean")) {
3161                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
3162                                viewType = TYPE_BUTTON_GRID_FIELD;
3163                            } else {
3164                                viewType = TYPE_CHECKBOX_FIELD;
3165                            }
3166                        } else if (
3167                            range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
3168                                "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
3169                                "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
3170                            )
3171                        ) {
3172                            // has a range and is numeric, use a slider
3173                            viewType = TYPE_SLIDER_FIELD;
3174                        } else if (fieldType.equals("list-single")) {
3175                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
3176                                viewType = TYPE_BUTTON_GRID_FIELD;
3177                            } else if (Option.forField(el).size() > 9) {
3178                                viewType = TYPE_SEARCH_LIST_FIELD;
3179                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
3180                                viewType = TYPE_RADIO_EDIT_FIELD;
3181                            } else {
3182                                viewType = TYPE_SPINNER_FIELD;
3183                            }
3184                        } else if (fieldType.equals("list-multi")) {
3185                            viewType = TYPE_SEARCH_LIST_FIELD;
3186                        } else {
3187                            viewType = TYPE_TEXT_FIELD;
3188                        }
3189                    }
3190
3191                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
3192                }
3193
3194                return null;
3195            }
3196
3197            protected Item mkItem(Element el, int pos) {
3198                int viewType = TYPE_ERROR;
3199
3200                if (response != null && response.getType() == Iq.Type.RESULT) {
3201                    if (el.getName().equals("note")) {
3202                        viewType = TYPE_NOTE;
3203                    } else if (el.getNamespace().equals("jabber:x:oob")) {
3204                        viewType = TYPE_WEB;
3205                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
3206                        viewType = TYPE_NOTE;
3207                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
3208                        Field field = mkField(el);
3209                        if (field != null) {
3210                            items.put(pos, field);
3211                            return field;
3212                        }
3213                    }
3214                }
3215
3216                Item item = new Item(el, viewType);
3217                items.put(pos, item);
3218                return item;
3219            }
3220
3221            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
3222                protected Context ctx;
3223
3224                public ActionsAdapter(Context ctx) {
3225                    super(ctx, R.layout.simple_list_item);
3226                    this.ctx = ctx;
3227                }
3228
3229                @Override
3230                public View getView(int position, View convertView, ViewGroup parent) {
3231                    View v = super.getView(position, convertView, parent);
3232                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
3233                    tv.setGravity(Gravity.CENTER);
3234                    tv.setText(getItem(position).second);
3235                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
3236                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
3237                    final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
3238                    tv.setTextColor(colors.getOnAccent());
3239                    tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
3240                    return v;
3241                }
3242
3243                public int getPosition(String s) {
3244                    for(int i = 0; i < getCount(); i++) {
3245                        if (getItem(i).first.equals(s)) return i;
3246                    }
3247                    return -1;
3248                }
3249
3250                public int countProceed() {
3251                    int count = 0;
3252                    for(int i = 0; i < getCount(); i++) {
3253                        if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
3254                    }
3255                    return count;
3256                }
3257
3258                public int countExceptCancel() {
3259                    int count = 0;
3260                    for(int i = 0; i < getCount(); i++) {
3261                        if (!getItem(i).first.equals("cancel")) count++;
3262                    }
3263                    return count;
3264                }
3265
3266                public void clearProceed() {
3267                    Pair<String,String> cancelItem = null;
3268                    Pair<String,String> prevItem = null;
3269                    for(int i = 0; i < getCount(); i++) {
3270                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3271                        if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3272                    }
3273                    clear();
3274                    if (cancelItem != null) add(cancelItem);
3275                    if (prevItem != null) add(prevItem);
3276                }
3277            }
3278
3279            final int TYPE_ERROR = 1;
3280            final int TYPE_NOTE = 2;
3281            final int TYPE_WEB = 3;
3282            final int TYPE_RESULT_FIELD = 4;
3283            final int TYPE_TEXT_FIELD = 5;
3284            final int TYPE_CHECKBOX_FIELD = 6;
3285            final int TYPE_SPINNER_FIELD = 7;
3286            final int TYPE_RADIO_EDIT_FIELD = 8;
3287            final int TYPE_RESULT_CELL = 9;
3288            final int TYPE_PROGRESSBAR = 10;
3289            final int TYPE_SEARCH_LIST_FIELD = 11;
3290            final int TYPE_ITEM_CARD = 12;
3291            final int TYPE_BUTTON_GRID_FIELD = 13;
3292            final int TYPE_SLIDER_FIELD = 14;
3293
3294            protected boolean executing = false;
3295            protected boolean loading = false;
3296            protected boolean loadingHasBeenLong = false;
3297            protected Timer loadingTimer = new Timer();
3298            protected String mTitle;
3299            protected String mNode;
3300            protected CommandPageBinding mBinding = null;
3301            protected Iq response = null;
3302            protected Element responseElement = null;
3303            protected boolean expectingRemoval = false;
3304            protected List<Field> reported = null;
3305            protected SparseArray<Item> items = new SparseArray<>();
3306            protected XmppConnectionService xmppConnectionService;
3307            protected ActionsAdapter actionsAdapter = null;
3308            protected GridLayoutManager layoutManager;
3309            protected WebView actionToWebview = null;
3310            protected int fillableFieldCount = 0;
3311            protected Iq pendingResponsePacket = null;
3312            protected boolean waitingForRefresh = false;
3313
3314            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3315                loading();
3316                mTitle = title;
3317                mNode = node;
3318                this.xmppConnectionService = xmppConnectionService;
3319                ViewPager pager = mPager.get();
3320                if (pager != null) setupLayoutManager(pager.getContext());
3321            }
3322
3323            public String getTitle() {
3324                return mTitle;
3325            }
3326
3327            public String getNode() {
3328                return mNode;
3329            }
3330
3331            public void updateWithResponse(final Iq iq) {
3332                if (getView() != null && getView().isAttachedToWindow()) {
3333                    getView().post(() -> updateWithResponseUiThread(iq));
3334                } else {
3335                    pendingResponsePacket = iq;
3336                }
3337            }
3338
3339            protected void updateWithResponseUiThread(final Iq iq) {
3340                Timer oldTimer = this.loadingTimer;
3341                this.loadingTimer = new Timer();
3342                oldTimer.cancel();
3343                this.executing = false;
3344                this.loading = false;
3345                this.loadingHasBeenLong = false;
3346                this.responseElement = null;
3347                this.fillableFieldCount = 0;
3348                this.reported = null;
3349                this.response = iq;
3350                this.items.clear();
3351                this.actionsAdapter.clear();
3352                layoutManager.setSpanCount(1);
3353
3354                boolean actionsCleared = false;
3355                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3356                if (iq.getType() == Iq.Type.RESULT && command != null) {
3357                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3358                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()));
3359                    }
3360
3361                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3362                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3363                    }
3364
3365                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3366                    if (actions != null) {
3367                        for (Element action : actions.getChildren()) {
3368                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3369                            if ("execute".equals(action.getName())) continue;
3370
3371                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3372                        }
3373                    }
3374
3375                    for (Element el : command.getChildren()) {
3376                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3377                            Data form = Data.parse(el);
3378                            String title = form.getTitle();
3379                            if (title != null) {
3380                                mTitle = title;
3381                                ConversationPagerAdapter.this.notifyDataSetChanged();
3382                            }
3383
3384                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3385                                this.responseElement = el;
3386                                setupReported(el.findChild("reported", "jabber:x:data"));
3387                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3388                            }
3389
3390                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3391                            if (actionList != null) {
3392                                actionsAdapter.clear();
3393
3394                                for (Option action : actionList.getOptions()) {
3395                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3396                                }
3397                            }
3398
3399                            eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3400                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3401                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3402                                   final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3403                                   final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3404                                    fillableField = range == null ? field : null;
3405                                    fillableFieldCount++;
3406                                }
3407                            }
3408
3409                            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))) {
3410                                actionsCleared = true;
3411                                actionsAdapter.clearProceed();
3412                            }
3413                            break;
3414                        }
3415                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3416                            String url = el.findChildContent("url", "jabber:x:oob");
3417                            if (url != null) {
3418                                String scheme = Uri.parse(url).getScheme();
3419                                if (scheme == null) {
3420                                    break;
3421                                }
3422                                if (scheme.equals("http") || scheme.equals("https")) {
3423                                    this.responseElement = el;
3424                                    break;
3425                                }
3426                                if (scheme.equals("xmpp")) {
3427                                    expectingRemoval = true;
3428                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3429                                    intent.setAction(Intent.ACTION_VIEW);
3430                                    intent.setData(Uri.parse(url));
3431                                    getView().getContext().startActivity(intent);
3432                                    break;
3433                                }
3434                            }
3435                        }
3436                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3437                            this.responseElement = el;
3438                            break;
3439                        }
3440                    }
3441
3442                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3443                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3444                            if (xmppConnectionService.isOnboarding()) {
3445                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3446                                    xmppConnectionService.deleteAccount(getAccount());
3447                                } else {
3448                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3449                                        removeSession(this);
3450                                        return;
3451                                    } else {
3452                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3453                                        xmppConnectionService.deleteAccount(getAccount());
3454                                    }
3455                                }
3456                            }
3457                            xmppConnectionService.archiveConversation(Conversation.this);
3458                        }
3459
3460                        expectingRemoval = true;
3461                        removeSession(this);
3462                        return;
3463                    }
3464
3465                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3466                        // No actions have been given, but we are not done?
3467                        // This is probably a spec violation, but we should do *something*
3468                        actionsAdapter.add(Pair.create("execute", "execute"));
3469                    }
3470
3471                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3472                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3473                            actionsAdapter.add(Pair.create("close", "close"));
3474                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3475                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3476                        }
3477                    }
3478                }
3479
3480                if (actionsAdapter.isEmpty()) {
3481                    actionsAdapter.add(Pair.create("close", "close"));
3482                }
3483
3484                actionsAdapter.sort((x, y) -> {
3485                    if (x.first.equals("cancel")) return -1;
3486                    if (y.first.equals("cancel")) return 1;
3487                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3488                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3489                    return 0;
3490                });
3491
3492                Data dataForm = null;
3493                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3494                if (mNode.equals("jabber:iq:register") &&
3495                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
3496                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3497
3498
3499                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3500                    execute();
3501                }
3502                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3503                notifyDataSetChanged();
3504            }
3505
3506            protected void setupReported(Element el) {
3507                if (el == null) {
3508                    reported = null;
3509                    return;
3510                }
3511
3512                reported = new ArrayList<>();
3513                for (Element fieldEl : el.getChildren()) {
3514                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3515                    reported.add(mkField(fieldEl));
3516                }
3517            }
3518
3519            @Override
3520            public int getItemCount() {
3521                if (loading) return 1;
3522                if (response == null) return 0;
3523                if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3524                    int i = 0;
3525                    for (Element el : responseElement.getChildren()) {
3526                        if (!el.getNamespace().equals("jabber:x:data")) continue;
3527                        if (el.getName().equals("title")) continue;
3528                        if (el.getName().equals("field")) {
3529                            String type = el.getAttribute("type");
3530                            if (type != null && type.equals("hidden")) continue;
3531                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3532                        }
3533
3534                        if (el.getName().equals("reported") || el.getName().equals("item")) {
3535                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3536                                if (el.getName().equals("reported")) continue;
3537                                i += 1;
3538                            } else {
3539                                if (reported != null) i += reported.size();
3540                            }
3541                            continue;
3542                        }
3543
3544                        i++;
3545                    }
3546                    return i;
3547                }
3548                return 1;
3549            }
3550
3551            public Item getItem(int position) {
3552                if (loading) return new Item(null, TYPE_PROGRESSBAR);
3553                if (items.get(position) != null) return items.get(position);
3554                if (response == null) return null;
3555
3556                if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3557                    if (responseElement.getNamespace().equals("jabber:x:data")) {
3558                        int i = 0;
3559                        for (Element el : responseElement.getChildren()) {
3560                            if (!el.getNamespace().equals("jabber:x:data")) continue;
3561                            if (el.getName().equals("title")) continue;
3562                            if (el.getName().equals("field")) {
3563                                String type = el.getAttribute("type");
3564                                if (type != null && type.equals("hidden")) continue;
3565                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3566                            }
3567
3568                            if (el.getName().equals("reported") || el.getName().equals("item")) {
3569                                Cell cell = null;
3570
3571                                if (reported != null) {
3572                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3573                                        if (el.getName().equals("reported")) continue;
3574                                        if (i == position) {
3575                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
3576                                            return items.get(position);
3577                                        }
3578                                    } else {
3579                                        if (reported.size() > position - i) {
3580                                            Field reportedField = reported.get(position - i);
3581                                            Element itemField = null;
3582                                            if (el.getName().equals("item")) {
3583                                                for (Element subel : el.getChildren()) {
3584                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
3585                                                       itemField = subel;
3586                                                       break;
3587                                                    }
3588                                                }
3589                                            }
3590                                            cell = new Cell(reportedField, itemField);
3591                                        } else {
3592                                            i += reported.size();
3593                                            continue;
3594                                        }
3595                                    }
3596                                }
3597
3598                                if (cell != null) {
3599                                    items.put(position, cell);
3600                                    return cell;
3601                                }
3602                            }
3603
3604                            if (i < position) {
3605                                i++;
3606                                continue;
3607                            }
3608
3609                            return mkItem(el, position);
3610                        }
3611                    }
3612                }
3613
3614                return mkItem(responseElement == null ? response : responseElement, position);
3615            }
3616
3617            @Override
3618            public int getItemViewType(int position) {
3619                return getItem(position).viewType;
3620            }
3621
3622            @Override
3623            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3624                switch(viewType) {
3625                    case TYPE_ERROR: {
3626                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3627                        return new ErrorViewHolder(binding);
3628                    }
3629                    case TYPE_NOTE: {
3630                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3631                        return new NoteViewHolder(binding);
3632                    }
3633                    case TYPE_WEB: {
3634                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3635                        return new WebViewHolder(binding);
3636                    }
3637                    case TYPE_RESULT_FIELD: {
3638                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3639                        return new ResultFieldViewHolder(binding);
3640                    }
3641                    case TYPE_RESULT_CELL: {
3642                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3643                        return new ResultCellViewHolder(binding);
3644                    }
3645                    case TYPE_ITEM_CARD: {
3646                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3647                        return new ItemCardViewHolder(binding);
3648                    }
3649                    case TYPE_CHECKBOX_FIELD: {
3650                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3651                        return new CheckboxFieldViewHolder(binding);
3652                    }
3653                    case TYPE_SEARCH_LIST_FIELD: {
3654                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3655                        return new SearchListFieldViewHolder(binding);
3656                    }
3657                    case TYPE_RADIO_EDIT_FIELD: {
3658                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3659                        return new RadioEditFieldViewHolder(binding);
3660                    }
3661                    case TYPE_SPINNER_FIELD: {
3662                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3663                        return new SpinnerFieldViewHolder(binding);
3664                    }
3665                    case TYPE_BUTTON_GRID_FIELD: {
3666                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3667                        return new ButtonGridFieldViewHolder(binding);
3668                    }
3669                    case TYPE_TEXT_FIELD: {
3670                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3671                        return new TextFieldViewHolder(binding);
3672                    }
3673                    case TYPE_SLIDER_FIELD: {
3674                        CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3675                        return new SliderFieldViewHolder(binding);
3676                    }
3677                    case TYPE_PROGRESSBAR: {
3678                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3679                        return new ProgressBarViewHolder(binding);
3680                    }
3681                    default:
3682                        if (expectingRemoval) {
3683                            CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3684                            return new NoteViewHolder(binding);
3685                        }
3686
3687                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3688                }
3689            }
3690
3691            @Override
3692            public void onBindViewHolder(ViewHolder viewHolder, int position) {
3693                viewHolder.bind(getItem(position));
3694            }
3695
3696            public View getView() {
3697                if (mBinding == null) return null;
3698                return mBinding.getRoot();
3699            }
3700
3701            public boolean validate() {
3702                int count = getItemCount();
3703                boolean isValid = true;
3704                for (int i = 0; i < count; i++) {
3705                    boolean oneIsValid = getItem(i).validate();
3706                    isValid = isValid && oneIsValid;
3707                }
3708                notifyDataSetChanged();
3709                return isValid;
3710            }
3711
3712            public boolean execute() {
3713                return execute("execute");
3714            }
3715
3716            public boolean execute(int actionPosition) {
3717                return execute(actionsAdapter.getItem(actionPosition).first);
3718            }
3719
3720            public synchronized boolean execute(String action) {
3721                if (!"cancel".equals(action) && executing) {
3722                    loadingHasBeenLong = true;
3723                    notifyDataSetChanged();
3724                    return false;
3725                }
3726                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3727
3728                if (response == null) return true;
3729                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3730                if (command == null) return true;
3731                String status = command.getAttribute("status");
3732                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3733
3734                if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3735                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3736                    return false;
3737                }
3738
3739                final var packet = new Iq(Iq.Type.SET);
3740                packet.setTo(response.getFrom());
3741                final Element c = packet.addChild("command", Namespace.COMMANDS);
3742                c.setAttribute("node", mNode);
3743                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3744
3745                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3746                if (!action.equals("cancel") &&
3747                    !action.equals("prev") &&
3748                    responseElement != null &&
3749                    responseElement.getName().equals("x") &&
3750                    responseElement.getNamespace().equals("jabber:x:data") &&
3751                    formType != null && formType.equals("form")) {
3752
3753                    Data form = Data.parse(responseElement);
3754                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3755                    if (actionList != null) {
3756                        actionList.setValue(action);
3757                        c.setAttribute("action", "execute");
3758                    }
3759
3760                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3761                        if (form.getValue("gateway-jid") == null) {
3762                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3763                        } else {
3764                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3765                        }
3766                    }
3767
3768                    responseElement.setAttribute("type", "submit");
3769                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3770                    if (rsm != null) {
3771                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3772                        max.setContent("1000");
3773                        rsm.addChild(max);
3774                    }
3775
3776                    c.addChild(responseElement);
3777                }
3778
3779                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3780
3781                executing = true;
3782                xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3783                    updateWithResponse(iq);
3784                }, 120L);
3785
3786                loading();
3787                return false;
3788            }
3789
3790            public void refresh() {
3791                synchronized(this) {
3792                    if (waitingForRefresh) notifyDataSetChanged();
3793                }
3794            }
3795
3796            protected void loading() {
3797                View v = getView();
3798                try {
3799                    loadingTimer.schedule(new TimerTask() {
3800                        @Override
3801                        public void run() {
3802                            View v2 = getView();
3803                            loading = true;
3804
3805                            try {
3806                                loadingTimer.schedule(new TimerTask() {
3807                                    @Override
3808                                    public void run() {
3809                                        loadingHasBeenLong = true;
3810                                        if (v == null && v2 == null) return;
3811                                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3812                                    }
3813                                }, 3000);
3814                            } catch (final IllegalStateException e) { }
3815
3816                            if (v == null && v2 == null) return;
3817                            (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3818                        }
3819                    }, 500);
3820                } catch (final IllegalStateException e) { }
3821            }
3822
3823            protected GridLayoutManager setupLayoutManager(final Context ctx) {
3824                int spanCount = 1;
3825
3826                if (reported != null) {
3827                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3828                    TextPaint paint = ((TextView) LayoutInflater.from(ctx).inflate(R.layout.command_result_cell, null)).getPaint();
3829                    float tableHeaderWidth = reported.stream().reduce(
3830                        0f,
3831                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3832                        (a, b) -> a + b
3833                    );
3834
3835                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3836                }
3837
3838                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3839                    items.clear();
3840                    notifyDataSetChanged();
3841                }
3842
3843                layoutManager = new GridLayoutManager(ctx, spanCount);
3844                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3845                    @Override
3846                    public int getSpanSize(int position) {
3847                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3848                        return 1;
3849                    }
3850                });
3851                return layoutManager;
3852            }
3853
3854            protected void setBinding(CommandPageBinding b) {
3855                mBinding = b;
3856                // https://stackoverflow.com/a/32350474/8611
3857                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3858                    @Override
3859                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3860                        if(rv.getChildCount() > 0) {
3861                            int[] location = new int[2];
3862                            rv.getLocationOnScreen(location);
3863                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3864                            if (childView instanceof ViewGroup) {
3865                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3866                            }
3867                            int action = e.getAction();
3868                            switch (action) {
3869                                case MotionEvent.ACTION_DOWN:
3870                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3871                                        rv.requestDisallowInterceptTouchEvent(true);
3872                                    }
3873                                case MotionEvent.ACTION_UP:
3874                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3875                                        rv.requestDisallowInterceptTouchEvent(true);
3876                                    }
3877                            }
3878                        }
3879
3880                        return false;
3881                    }
3882
3883                    @Override
3884                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3885
3886                    @Override
3887                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3888                });
3889                mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3890                mBinding.form.setAdapter(this);
3891
3892                if (actionsAdapter == null) {
3893                    actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3894                    actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3895                        @Override
3896                        public void onChanged() {
3897                            if (mBinding == null) return;
3898
3899                            mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3900                        }
3901
3902                        @Override
3903                        public void onInvalidated() {}
3904                    });
3905                }
3906
3907                mBinding.actions.setAdapter(actionsAdapter);
3908                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3909                    if (execute(pos)) {
3910                        removeSession(CommandSession.this);
3911                    }
3912                });
3913
3914                actionsAdapter.notifyDataSetChanged();
3915
3916                if (pendingResponsePacket != null) {
3917                    final var pending = pendingResponsePacket;
3918                    pendingResponsePacket = null;
3919                    updateWithResponseUiThread(pending);
3920                }
3921            }
3922
3923            private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3924               if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image"))  {
3925                   return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3926               } else {
3927                   return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3928               }
3929            }
3930
3931            private Drawable getDrawableForUrl(final String url) {
3932                final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3933                final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3934                final Drawable d = cache.get(url);
3935                if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3936                if (d == null) {
3937                    synchronized (CommandSession.this) {
3938                        waitingForRefresh = true;
3939                    }
3940                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3941                    Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3942                    dummy.setStatus(Message.STATUS_DUMMY);
3943                    dummy.setFileParams(new Message.FileParams(url));
3944                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3945                        if (file == null) {
3946                            dummy.getTransferable().start();
3947                        } else {
3948                            try {
3949                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3950                            } catch (final Exception e) { }
3951                        }
3952                    });
3953                }
3954                return d;
3955            }
3956
3957            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3958                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3959                setBinding(binding);
3960                return binding.getRoot();
3961            }
3962
3963            // https://stackoverflow.com/a/36037991/8611
3964            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3965                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3966                    View child = viewGroup.getChildAt(i);
3967                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3968                        View foundView = findViewAt((ViewGroup) child, x, y);
3969                        if (foundView != null && foundView.isShown()) {
3970                            return foundView;
3971                        }
3972                    } else {
3973                        int[] location = new int[2];
3974                        child.getLocationOnScreen(location);
3975                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3976                        if (rect.contains((int)x, (int)y)) {
3977                            return child;
3978                        }
3979                    }
3980                }
3981
3982                return null;
3983            }
3984        }
3985
3986        class MucConfigSession extends CommandSession {
3987            MucConfigSession(XmppConnectionService xmppConnectionService) {
3988                super("Configure Channel", null, xmppConnectionService);
3989            }
3990
3991            @Override
3992            protected void updateWithResponseUiThread(final Iq iq) {
3993                Timer oldTimer = this.loadingTimer;
3994                this.loadingTimer = new Timer();
3995                oldTimer.cancel();
3996                this.executing = false;
3997                this.loading = false;
3998                this.loadingHasBeenLong = false;
3999                this.responseElement = null;
4000                this.fillableFieldCount = 0;
4001                this.reported = null;
4002                this.response = iq;
4003                this.items.clear();
4004                this.actionsAdapter.clear();
4005                layoutManager.setSpanCount(1);
4006
4007                final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
4008                if (iq.getType() == Iq.Type.RESULT && query != null) {
4009                    final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
4010                    final String title = form.getTitle();
4011                    if (title != null) {
4012                        mTitle = title;
4013                        ConversationPagerAdapter.this.notifyDataSetChanged();
4014                    }
4015
4016                    this.responseElement = form;
4017                    setupReported(form.findChild("reported", "jabber:x:data"));
4018                    if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
4019
4020                    if (actionsAdapter.countExceptCancel() < 1) {
4021                        actionsAdapter.add(Pair.create("save", "Save"));
4022                    }
4023
4024                    if (actionsAdapter.getPosition("cancel") < 0) {
4025                        actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
4026                    }
4027                } else if (iq.getType() == Iq.Type.RESULT) {
4028                    expectingRemoval = true;
4029                    removeSession(this);
4030                    return;
4031                } else {
4032                    actionsAdapter.add(Pair.create("close", "close"));
4033                }
4034
4035                notifyDataSetChanged();
4036            }
4037
4038            @Override
4039            public synchronized boolean execute(String action) {
4040                if ("cancel".equals(action)) {
4041                    final var packet = new Iq(Iq.Type.SET);
4042                    packet.setTo(response.getFrom());
4043                    final Element form = packet
4044                        .addChild("query", "http://jabber.org/protocol/muc#owner")
4045                        .addChild("x", "jabber:x:data");
4046                    form.setAttribute("type", "cancel");
4047                    xmppConnectionService.sendIqPacket(getAccount(), packet, null);
4048                    return true;
4049                }
4050
4051                if (!"save".equals(action)) return true;
4052
4053                final var packet = new Iq(Iq.Type.SET);
4054                packet.setTo(response.getFrom());
4055
4056                String formType = responseElement == null ? null : responseElement.getAttribute("type");
4057                if (responseElement != null &&
4058                    responseElement.getName().equals("x") &&
4059                    responseElement.getNamespace().equals("jabber:x:data") &&
4060                    formType != null && formType.equals("form")) {
4061
4062                    responseElement.setAttribute("type", "submit");
4063                    packet
4064                        .addChild("query", "http://jabber.org/protocol/muc#owner")
4065                        .addChild(responseElement);
4066                }
4067
4068                executing = true;
4069                xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
4070                    updateWithResponse(iq);
4071                }, 120L);
4072
4073                loading();
4074
4075                return false;
4076            }
4077        }
4078    }
4079
4080    public static class Thread {
4081        protected Message subject = null;
4082        protected Message first = null;
4083        protected Message last = null;
4084        protected final String threadId;
4085
4086        protected Thread(final String threadId) {
4087            this.threadId = threadId;
4088        }
4089
4090        public String getThreadId() {
4091            return threadId;
4092        }
4093
4094        public String getSubject() {
4095            if (subject == null) return null;
4096
4097            return subject.getSubject();
4098        }
4099
4100        public String getDisplay() {
4101            final String s = getSubject();
4102            if (s != null) return s;
4103
4104            if (first != null) {
4105                return first.getBody();
4106            }
4107
4108            return "";
4109        }
4110
4111        public long getLastTime() {
4112            if (last == null) return 0;
4113
4114            return last.getTimeSent();
4115        }
4116    }
4117}