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