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