Conversation.java

   1package eu.siacs.conversations.entities;
   2
   3import static eu.siacs.conversations.entities.Bookmark.printableValue;
   4
   5import android.content.ContentValues;
   6import android.content.Context;
   7import android.content.DialogInterface;
   8import android.content.Intent;
   9import android.database.Cursor;
  10import android.database.DataSetObserver;
  11import android.graphics.drawable.AnimatedImageDrawable;
  12import android.graphics.drawable.BitmapDrawable;
  13import android.graphics.drawable.Drawable;
  14import android.graphics.Bitmap;
  15import android.graphics.Canvas;
  16import android.graphics.Rect;
  17import android.os.Build;
  18import android.net.Uri;
  19import android.telephony.PhoneNumberUtils;
  20import android.text.Editable;
  21import android.text.InputType;
  22import android.text.SpannableStringBuilder;
  23import android.text.Spanned;
  24import android.text.StaticLayout;
  25import android.text.TextPaint;
  26import android.text.TextUtils;
  27import android.text.TextWatcher;
  28import android.text.style.ImageSpan;
  29import android.view.LayoutInflater;
  30import android.view.MotionEvent;
  31import android.view.Gravity;
  32import android.view.View;
  33import android.view.ViewGroup;
  34import android.widget.AbsListView;
  35import android.widget.ArrayAdapter;
  36import android.widget.AdapterView;
  37import android.widget.Button;
  38import android.widget.CompoundButton;
  39import android.widget.GridLayout;
  40import android.widget.ListView;
  41import android.widget.TextView;
  42import android.widget.Toast;
  43import android.widget.Spinner;
  44import android.webkit.JavascriptInterface;
  45import android.webkit.WebMessage;
  46import android.webkit.WebView;
  47import android.webkit.WebViewClient;
  48import android.webkit.WebChromeClient;
  49import android.util.DisplayMetrics;
  50import android.util.LruCache;
  51import android.util.Pair;
  52import android.util.SparseArray;
  53import android.util.SparseBooleanArray;
  54
  55import androidx.annotation.NonNull;
  56import androidx.annotation.Nullable;
  57import androidx.appcompat.app.AlertDialog;
  58import androidx.appcompat.app.AlertDialog.Builder;
  59import androidx.core.content.ContextCompat;
  60import androidx.core.util.Consumer;
  61import androidx.databinding.DataBindingUtil;
  62import androidx.databinding.ViewDataBinding;
  63import androidx.viewpager.widget.PagerAdapter;
  64import androidx.recyclerview.widget.RecyclerView;
  65import androidx.recyclerview.widget.GridLayoutManager;
  66import androidx.viewpager.widget.ViewPager;
  67
  68import com.caverock.androidsvg.SVG;
  69
  70import com.cheogram.android.BobTransfer;
  71import com.cheogram.android.ConversationPage;
  72import com.cheogram.android.GetThumbnailForCid;
  73import com.cheogram.android.Util;
  74import com.cheogram.android.WebxdcPage;
  75
  76import com.google.android.material.color.MaterialColors;
  77import com.google.android.material.tabs.TabLayout;
  78import com.google.android.material.textfield.TextInputLayout;
  79import com.google.common.base.Optional;
  80import com.google.common.base.Strings;
  81import com.google.common.collect.ComparisonChain;
  82import com.google.common.collect.HashMultimap;
  83import com.google.common.collect.ImmutableList;
  84import com.google.common.collect.Lists;
  85import com.google.common.collect.Multimap;
  86
  87import io.ipfs.cid.Cid;
  88
  89import io.michaelrocks.libphonenumber.android.NumberParseException;
  90
  91import org.json.JSONArray;
  92import org.json.JSONException;
  93import org.json.JSONObject;
  94
  95import java.lang.ref.WeakReference;
  96import java.time.LocalDateTime;
  97import java.time.ZoneId;
  98import java.time.ZonedDateTime;
  99import java.time.format.DateTimeParseException;
 100import java.time.format.DateTimeFormatter;
 101import java.time.format.FormatStyle;
 102import java.text.DecimalFormat;
 103import java.util.ArrayList;
 104import java.util.Collection;
 105import java.util.Collections;
 106import java.util.Iterator;
 107import java.util.HashMap;
 108import java.util.HashSet;
 109import java.util.List;
 110import java.util.ListIterator;
 111import java.util.concurrent.atomic.AtomicBoolean;
 112import java.util.stream.Collectors;
 113import java.util.Set;
 114import java.util.Timer;
 115import java.util.TimerTask;
 116import java.util.function.Function;
 117
 118import me.saket.bettermovementmethod.BetterLinkMovementMethod;
 119
 120import eu.siacs.conversations.Config;
 121import eu.siacs.conversations.R;
 122import eu.siacs.conversations.crypto.OmemoSetting;
 123import eu.siacs.conversations.crypto.PgpDecryptionService;
 124import eu.siacs.conversations.databinding.CommandButtonGridFieldBinding;
 125import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
 126import eu.siacs.conversations.databinding.CommandItemCardBinding;
 127import eu.siacs.conversations.databinding.CommandNoteBinding;
 128import eu.siacs.conversations.databinding.CommandPageBinding;
 129import eu.siacs.conversations.databinding.CommandProgressBarBinding;
 130import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
 131import eu.siacs.conversations.databinding.CommandResultCellBinding;
 132import eu.siacs.conversations.databinding.CommandResultFieldBinding;
 133import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
 134import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
 135import eu.siacs.conversations.databinding.CommandTextFieldBinding;
 136import eu.siacs.conversations.databinding.CommandSliderFieldBinding;
 137import eu.siacs.conversations.databinding.CommandWebviewBinding;
 138import eu.siacs.conversations.databinding.DialogQuickeditBinding;
 139import eu.siacs.conversations.entities.Reaction;
 140import eu.siacs.conversations.entities.ListItem.Tag;
 141import eu.siacs.conversations.http.HttpConnectionManager;
 142import eu.siacs.conversations.persistance.DatabaseBackend;
 143import eu.siacs.conversations.services.AvatarService;
 144import eu.siacs.conversations.services.QuickConversationsService;
 145import eu.siacs.conversations.services.XmppConnectionService;
 146import eu.siacs.conversations.ui.UriHandlerActivity;
 147import eu.siacs.conversations.ui.text.FixedURLSpan;
 148import eu.siacs.conversations.ui.util.ShareUtil;
 149import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 150import eu.siacs.conversations.utils.Emoticons;
 151import eu.siacs.conversations.utils.JidHelper;
 152import eu.siacs.conversations.utils.MessageUtils;
 153import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
 154import eu.siacs.conversations.utils.UIHelper;
 155import eu.siacs.conversations.xml.Element;
 156import eu.siacs.conversations.xml.Namespace;
 157import eu.siacs.conversations.xmpp.Jid;
 158import eu.siacs.conversations.xmpp.chatstate.ChatState;
 159import eu.siacs.conversations.xmpp.forms.Data;
 160import eu.siacs.conversations.xmpp.forms.Option;
 161import eu.siacs.conversations.xmpp.mam.MamReference;
 162import java.util.ArrayList;
 163import java.util.Collections;
 164import java.util.List;
 165import java.util.ListIterator;
 166import java.util.concurrent.atomic.AtomicBoolean;
 167import org.json.JSONArray;
 168import org.json.JSONException;
 169import org.json.JSONObject;
 170
 171import static eu.siacs.conversations.entities.Bookmark.printableValue;
 172
 173import im.conversations.android.xmpp.model.stanza.Iq;
 174
 175public class Conversation extends AbstractEntity
 176        implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
 177    public static final String TABLENAME = "conversations";
 178
 179    public static final int STATUS_AVAILABLE = 0;
 180    public static final int STATUS_ARCHIVED = 1;
 181
 182    public static final String NAME = "name";
 183    public static final String ACCOUNT = "accountUuid";
 184    public static final String CONTACT = "contactUuid";
 185    public static final String CONTACTJID = "contactJid";
 186    public static final String STATUS = "status";
 187    public static final String CREATED = "created";
 188    public static final String MODE = "mode";
 189    public static final String ATTRIBUTES = "attributes";
 190
 191    public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
 192    public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
 193    public static final String ATTRIBUTE_NOTIFY_REPLIES = "notify_replies";
 194    public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
 195    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS =
 196            "formerly_private_non_anonymous";
 197    public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
 198    static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
 199    static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
 200    static final String ATTRIBUTE_MODERATED = "moderated";
 201    static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
 202    private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
 203    private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
 204    private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
 205    private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
 206    private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
 207    protected final ArrayList<Message> messages = new ArrayList<>();
 208    public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
 209    protected Account account = null;
 210    private String draftMessage;
 211    private final String name;
 212    private final String contactUuid;
 213    private final String accountUuid;
 214    private Jid contactJid;
 215    private int status;
 216    private final long created;
 217    private int mode;
 218    private final JSONObject attributes;
 219    private Jid nextCounterpart;
 220    private transient MucOptions mucOptions = null;
 221    private boolean messagesLeftOnServer = true;
 222    private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
 223    private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
 224    private String mFirstMamReference = null;
 225    protected int mCurrentTab = -1;
 226    protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
 227    protected Element thread = null;
 228    protected boolean lockThread = false;
 229    protected boolean userSelectedThread = false;
 230    protected Message replyTo = null;
 231    protected HashMap<String, Thread> threads = new HashMap<>();
 232    protected Multimap<String, Reaction> reactions = HashMultimap.create();
 233    private String displayState = null;
 234    protected boolean anyMatchSpam = false;
 235
 236    public Conversation(
 237            final String name, final Account account, final Jid contactJid, final int mode) {
 238        this(
 239                java.util.UUID.randomUUID().toString(),
 240                name,
 241                null,
 242                account.getUuid(),
 243                contactJid,
 244                System.currentTimeMillis(),
 245                STATUS_AVAILABLE,
 246                mode,
 247                "");
 248        this.account = account;
 249    }
 250
 251    public Conversation(
 252            final String uuid,
 253            final String name,
 254            final String contactUuid,
 255            final String accountUuid,
 256            final Jid contactJid,
 257            final long created,
 258            final int status,
 259            final int mode,
 260            final String attributes) {
 261        this.uuid = uuid;
 262        this.name = name;
 263        this.contactUuid = contactUuid;
 264        this.accountUuid = accountUuid;
 265        this.contactJid = contactJid;
 266        this.created = created;
 267        this.status = status;
 268        this.mode = mode;
 269        this.attributes = parseAttributes(attributes);
 270    }
 271
 272    private static JSONObject parseAttributes(final String attributes) {
 273        if (Strings.isNullOrEmpty(attributes)) {
 274            return new JSONObject();
 275        } else {
 276            try {
 277                return new JSONObject(attributes);
 278            } catch (final JSONException e) {
 279                return new JSONObject();
 280            }
 281        }
 282    }
 283
 284    public static Conversation fromCursor(final Cursor cursor) {
 285        return new Conversation(
 286                cursor.getString(cursor.getColumnIndexOrThrow(UUID)),
 287                cursor.getString(cursor.getColumnIndexOrThrow(NAME)),
 288                cursor.getString(cursor.getColumnIndexOrThrow(CONTACT)),
 289                cursor.getString(cursor.getColumnIndexOrThrow(ACCOUNT)),
 290                Jid.ofOrInvalid(cursor.getString(cursor.getColumnIndexOrThrow(CONTACTJID))),
 291                cursor.getLong(cursor.getColumnIndexOrThrow(CREATED)),
 292                cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)),
 293                cursor.getInt(cursor.getColumnIndexOrThrow(MODE)),
 294                cursor.getString(cursor.getColumnIndexOrThrow(ATTRIBUTES)));
 295    }
 296
 297    public static Message getLatestMarkableMessage(
 298            final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
 299        for (int i = messages.size() - 1; i >= 0; --i) {
 300            final Message message = messages.get(i);
 301            if (message.getStatus() <= Message.STATUS_RECEIVED
 302                    && (message.markable || isPrivateAndNonAnonymousMuc)
 303                    && !message.isPrivateMessage()) {
 304                return message;
 305            }
 306        }
 307        return null;
 308    }
 309
 310    private static boolean suitableForOmemoByDefault(final Conversation conversation) {
 311        if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
 312            return false;
 313        }
 314        if (conversation.getContact().isOwnServer()) {
 315            return false;
 316        }
 317        final String contact = conversation.getJid().getDomain().toString();
 318        final String account = conversation.getAccount().getServer();
 319        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact)
 320                || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
 321            return false;
 322        }
 323        return conversation.isSingleOrPrivateAndNonAnonymous()
 324                || conversation.getBooleanAttribute(
 325                        ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
 326    }
 327
 328    public boolean hasMessagesLeftOnServer() {
 329        return messagesLeftOnServer;
 330    }
 331
 332    public void setHasMessagesLeftOnServer(boolean value) {
 333        this.messagesLeftOnServer = value;
 334    }
 335
 336    public Message getFirstUnreadMessage() {
 337        Message first = null;
 338        synchronized (this.messages) {
 339            for (int i = messages.size() - 1; i >= 0; --i) {
 340                final Message message = messages.get(i);
 341                if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
 342                if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
 343                if (asReaction(message) != null) continue;
 344                if (message.isRead()) {
 345                    return first;
 346                } else {
 347                    first = message;
 348                }
 349            }
 350        }
 351        return first;
 352    }
 353
 354    public String findMostRecentRemoteDisplayableId() {
 355        final boolean multi = mode == Conversation.MODE_MULTI;
 356        synchronized (this.messages) {
 357            for (int i = messages.size() - 1; i >= 0; --i) {
 358                final Message message = messages.get(i);
 359                if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
 360                if ((message.getRawBody() == null || "".equals(message.getRawBody()) || " ".equals(message.getRawBody())) && message.getReply() != null && message.edited() && message.getHtml() != null) continue;
 361                if (asReaction(message) != null) continue;
 362                if (message.getStatus() == Message.STATUS_RECEIVED) {
 363                    final String serverMsgId = message.getServerMsgId();
 364                    if (serverMsgId != null && multi) {
 365                        return serverMsgId;
 366                    }
 367                    return message.getRemoteMsgId();
 368                }
 369            }
 370        }
 371        return null;
 372    }
 373
 374    public int countFailedDeliveries() {
 375        int count = 0;
 376        synchronized (this.messages) {
 377            for (final Message message : this.messages) {
 378                if (message.getStatus() == Message.STATUS_SEND_FAILED) {
 379                    ++count;
 380                }
 381            }
 382        }
 383        return count;
 384    }
 385
 386    public Message getLastEditableMessage() {
 387        synchronized (this.messages) {
 388            for (int i = messages.size() - 1; i >= 0; --i) {
 389                final Message message = messages.get(i);
 390                if (message.isEditable()) {
 391                    if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
 392                        return null;
 393                    }
 394                    return message;
 395                }
 396            }
 397        }
 398        return null;
 399    }
 400
 401    public Message findUnsentMessageWithUuid(String uuid) {
 402        synchronized (this.messages) {
 403            for (final Message message : this.messages) {
 404                final int s = message.getStatus();
 405                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING)
 406                        && message.getUuid().equals(uuid)) {
 407                    return message;
 408                }
 409            }
 410        }
 411        return null;
 412    }
 413
 414    public void findWaitingMessages(OnMessageFound onMessageFound) {
 415        final ArrayList<Message> results = new ArrayList<>();
 416        synchronized (this.messages) {
 417            for (Message message : this.messages) {
 418                if (message.getStatus() == Message.STATUS_WAITING) {
 419                    results.add(message);
 420                }
 421            }
 422        }
 423        for (Message result : results) {
 424            onMessageFound.onMessageFound(result);
 425        }
 426    }
 427
 428    public void findMessagesAndCallsToNotify(OnMessageFound onMessageFound) {
 429        final ArrayList<Message> results = new ArrayList<>();
 430        synchronized (this.messages) {
 431            for (final Message message : this.messages) {
 432                if (message.isRead() || message.notificationWasDismissed()) {
 433                    continue;
 434                }
 435                results.add(message);
 436            }
 437        }
 438        for (final Message result : results) {
 439            onMessageFound.onMessageFound(result);
 440        }
 441    }
 442
 443    public Message findMessageWithFileAndUuid(final String uuid) {
 444        synchronized (this.messages) {
 445            for (final Message message : this.messages) {
 446                final Transferable transferable = message.getTransferable();
 447                final boolean unInitiatedButKnownSize =
 448                        MessageUtils.unInitiatedButKnownSize(message);
 449                if (message.getUuid().equals(uuid)
 450                        && message.getEncryption() != Message.ENCRYPTION_PGP
 451                        && (message.isFileOrImage()
 452                                || message.treatAsDownloadable()
 453                                || unInitiatedButKnownSize
 454                                || (transferable != null
 455                                        && transferable.getStatus()
 456                                                != Transferable.STATUS_UPLOADING))) {
 457                    return message;
 458                }
 459            }
 460        }
 461        return null;
 462    }
 463
 464    public Message findMessageWithUuid(final String uuid) {
 465        synchronized (this.messages) {
 466            for (final Message message : this.messages) {
 467                if (message.getUuid().equals(uuid)) {
 468                    return message;
 469                }
 470            }
 471        }
 472        return null;
 473    }
 474
 475    public boolean markAsDeleted(final List<String> uuids) {
 476        boolean deleted = false;
 477        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 478        synchronized (this.messages) {
 479            for (Message message : this.messages) {
 480                if (uuids.contains(message.getUuid())) {
 481                    message.setDeleted(true);
 482                    deleted = true;
 483                    if (message.getEncryption() == Message.ENCRYPTION_PGP
 484                            && pgpDecryptionService != null) {
 485                        pgpDecryptionService.discard(message);
 486                    }
 487                }
 488            }
 489        }
 490        return deleted;
 491    }
 492
 493    public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
 494        boolean changed = false;
 495        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 496        synchronized (this.messages) {
 497            for (Message message : this.messages) {
 498                for (final DatabaseBackend.FilePathInfo file : files)
 499                    if (file.uuid.toString().equals(message.getUuid())) {
 500                        message.setDeleted(file.deleted);
 501                        changed = true;
 502                        if (file.deleted
 503                                && message.getEncryption() == Message.ENCRYPTION_PGP
 504                                && pgpDecryptionService != null) {
 505                            pgpDecryptionService.discard(message);
 506                        }
 507                    }
 508            }
 509        }
 510        return changed;
 511    }
 512
 513    public void clearMessages() {
 514        synchronized (this.messages) {
 515            this.messages.clear();
 516        }
 517    }
 518
 519    public boolean setIncomingChatState(ChatState state) {
 520        if (this.mIncomingChatState == state) {
 521            return false;
 522        }
 523        this.mIncomingChatState = state;
 524        return true;
 525    }
 526
 527    public ChatState getIncomingChatState() {
 528        return this.mIncomingChatState;
 529    }
 530
 531    public boolean setOutgoingChatState(ChatState state) {
 532        if (mode == MODE_SINGLE && !getContact().isSelf()
 533                || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
 534            if (this.mOutgoingChatState != state) {
 535                this.mOutgoingChatState = state;
 536                return true;
 537            }
 538        }
 539        return false;
 540    }
 541
 542    public ChatState getOutgoingChatState() {
 543        return this.mOutgoingChatState;
 544    }
 545
 546    public void trim() {
 547        synchronized (this.messages) {
 548            final int size = messages.size();
 549            final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
 550            if (size > maxsize) {
 551                List<Message> discards = this.messages.subList(0, size - maxsize);
 552                final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 553                if (pgpDecryptionService != null) {
 554                    pgpDecryptionService.discard(discards);
 555                }
 556                discards.clear();
 557                untieMessages();
 558            }
 559        }
 560    }
 561
 562    public void findUnsentTextMessages(OnMessageFound onMessageFound) {
 563        final ArrayList<Message> results = new ArrayList<>();
 564        synchronized (this.messages) {
 565            for (Message message : this.messages) {
 566                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost())
 567                        && message.getStatus() == Message.STATUS_UNSEND) {
 568                    results.add(message);
 569                }
 570            }
 571        }
 572        for (Message result : results) {
 573            onMessageFound.onMessageFound(result);
 574        }
 575    }
 576
 577    public Message findSentMessageWithUuidOrRemoteId(String id) {
 578        synchronized (this.messages) {
 579            for (Message message : this.messages) {
 580                if (id.equals(message.getUuid())
 581                        || (message.getStatus() >= Message.STATUS_SEND
 582                                && id.equals(message.getRemoteMsgId()))) {
 583                    return message;
 584                }
 585            }
 586        }
 587        return null;
 588    }
 589
 590    public Message findMessageWithUuidOrRemoteId(final String id) {
 591        synchronized (this.messages) {
 592            for (final Message message : this.messages) {
 593                if (id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId())) {
 594                    return message;
 595                }
 596            }
 597        }
 598        return null;
 599    }
 600
 601    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
 602        synchronized (this.messages) {
 603            for (int i = this.messages.size() - 1; i >= 0; --i) {
 604                final Message message = messages.get(i);
 605                final Jid mcp = message.getCounterpart();
 606                if (mcp == null && counterpart != null) {
 607                    continue;
 608                }
 609                if (counterpart == null || mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
 610                    final boolean idMatch = id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId()) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
 611                    if (idMatch) return message;
 612                }
 613            }
 614        }
 615        return null;
 616    }
 617
 618    public Message findSentMessageWithUuid(String id) {
 619        synchronized (this.messages) {
 620            for (Message message : this.messages) {
 621                if (id.equals(message.getUuid())) {
 622                    return message;
 623                }
 624            }
 625        }
 626        return null;
 627    }
 628
 629    public Message findMessageWithRemoteId(String id, Jid counterpart) {
 630        synchronized (this.messages) {
 631            for (Message message : this.messages) {
 632                if (counterpart.equals(message.getCounterpart())
 633                        && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
 634                    return message;
 635                }
 636            }
 637        }
 638        return null;
 639    }
 640
 641    public Message findReceivedWithRemoteId(final String id) {
 642        synchronized (this.messages) {
 643            for (final Message message : this.messages) {
 644                if (message.getStatus() == Message.STATUS_RECEIVED
 645                        && id.equals(message.getRemoteMsgId())) {
 646                    return message;
 647                }
 648            }
 649        }
 650        return null;
 651    }
 652
 653    public Message findMessageWithServerMsgId(String id) {
 654        synchronized (this.messages) {
 655            for (Message message : this.messages) {
 656                if (id != null && id.equals(message.getServerMsgId())) {
 657                    return message;
 658                }
 659            }
 660        }
 661        return null;
 662    }
 663
 664    public boolean hasMessageWithCounterpart(Jid counterpart) {
 665        synchronized (this.messages) {
 666            for (Message message : this.messages) {
 667                if (counterpart.equals(message.getCounterpart())) {
 668                    return true;
 669                }
 670            }
 671        }
 672        return false;
 673    }
 674
 675    public Message findMessageReactingTo(String id, Jid reactor) {
 676        if (id == null) return null;
 677
 678        synchronized (this.messages) {
 679            for (int i = this.messages.size() - 1; i >= 0; --i) {
 680                final Message message = messages.get(i);
 681                if (reactor == null && message.getStatus() < Message.STATUS_SEND) continue;
 682                if (reactor != null && message.getCounterpart() == null) continue;
 683                if (reactor != null && !(message.getCounterpart().equals(reactor) || message.getCounterpart().asBareJid().equals(reactor))) continue;
 684
 685                final Element r = message.getReactionsEl();
 686                if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
 687                    return message;
 688                }
 689            }
 690        }
 691        return null;
 692    }
 693
 694    public List<Message> findMessagesBy(MucOptions.User user) {
 695        List<Message> result = new ArrayList<>();
 696        synchronized (this.messages) {
 697            for (Message m : this.messages) {
 698                // occupant id?
 699                final Jid trueCp = m.getTrueCounterpart();
 700                if (m.getCounterpart().equals(user.getFullJid()) || (trueCp != null && trueCp.equals(user.getRealJid()))) {
 701                    result.add(m);
 702                }
 703            }
 704        }
 705        return result;
 706    }
 707
 708    public Set<String> findReactionsTo(String id, Jid reactor) {
 709        Set<String> reactionEmoji = new HashSet<>();
 710        Message reactM = findMessageReactingTo(id, reactor);
 711        Element reactions = reactM == null ? null : reactM.getReactionsEl();
 712        if (reactions != null) {
 713            for (Element el : reactions.getChildren()) {
 714                if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
 715                    reactionEmoji.add(el.getContent());
 716                }
 717            }
 718        }
 719        return reactionEmoji;
 720    }
 721
 722    public Set<Message> findReplies(String id) {
 723        Set<Message> replies = new HashSet<>();
 724        if (id == null) return replies;
 725
 726        synchronized (this.messages) {
 727            for (int i = this.messages.size() - 1; i >= 0; --i) {
 728                final Message message = messages.get(i);
 729                if (id.equals(message.getServerMsgId())) break;
 730                if (id.equals(message.getUuid())) break;
 731                final Element r = message.getReply();
 732                if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) {
 733                    replies.add(message);
 734                }
 735            }
 736        }
 737        return replies;
 738    }
 739
 740    public long loadMoreTimestamp() {
 741        if (messages.size() < 1) return 0;
 742        if (getLockThread() && messages.size() > 5000) return 0;
 743
 744        if (messages.get(0).getType() == Message.TYPE_STATUS && messages.size() >= 2) {
 745            return messages.get(1).getTimeSent();
 746        } else {
 747            return messages.get(0).getTimeSent();
 748        }
 749    }
 750
 751    public void populateWithMessages(final List<Message> messages, XmppConnectionService xmppConnectionService) {
 752        synchronized (this.messages) {
 753            messages.clear();
 754            messages.addAll(this.messages);
 755            threads.clear();
 756            reactions.clear();
 757        }
 758        Set<String> extraIds = new HashSet<>();
 759        for (ListIterator<Message> iterator = messages.listIterator(messages.size()); iterator.hasPrevious(); ) {
 760            Message m = iterator.previous();
 761            final Element mthread = m.getThread();
 762            if (mthread != null) {
 763                Thread thread = threads.get(mthread.getContent());
 764                if (thread == null) {
 765                    thread = new Thread(mthread.getContent());
 766                    threads.put(mthread.getContent(), thread);
 767                }
 768                if (thread.subject == null && (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0))) {
 769                    thread.subject = m;
 770                } else {
 771                    if (thread.last == null) thread.last = m;
 772                    thread.first = m;
 773                }
 774            }
 775
 776            if ((m.getRawBody() == null || "".equals(m.getRawBody()) || " ".equals(m.getRawBody())) && m.getReply() != null && m.edited() && m.getHtml() != null) {
 777                iterator.remove();
 778                continue;
 779            }
 780
 781            final var asReaction = asReaction(m);
 782            if (asReaction != null) {
 783                reactions.put(asReaction.first, asReaction.second);
 784                iterator.remove();
 785            } else if (m.wasMergedIntoPrevious(xmppConnectionService) || (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0)) || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
 786                iterator.remove();
 787            } else if (getLockThread() && mthread != null) {
 788                final var reply = m.getReply();
 789                if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
 790                Element reactions = m.getReactionsEl();
 791                if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
 792            }
 793        }
 794    }
 795
 796    protected Pair<String, Reaction> asReaction(Message m) {
 797        final var reply = m.getReply();
 798        if (reply != null && reply.getAttribute("id") != null) {
 799            final String envelopeId;
 800            if (m.isCarbon() || m.getStatus() == Message.STATUS_RECEIVED) {
 801                envelopeId = m.getRemoteMsgId();
 802            } else {
 803                envelopeId = m.getUuid();
 804            }
 805
 806            final var body = m.getBody(true).toString().replaceAll("\\s", "");
 807            if (Emoticons.isEmoji(body)) {
 808                return new Pair<>(reply.getAttribute("id"), new Reaction(body, null, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId(), envelopeId));
 809            } else {
 810                final var html = m.getHtml();
 811                if (html == null) return null;
 812
 813                SpannableStringBuilder spannable = m.getSpannableBody(null, null, false);
 814                ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
 815                for (ImageSpan span : imageSpans) {
 816                    final int start = spannable.getSpanStart(span);
 817                    final int end = spannable.getSpanEnd(span);
 818                    spannable.delete(start, end);
 819                }
 820                if (imageSpans.length == 1 && spannable.toString().replaceAll("\\s", "").length() < 1) {
 821                    // Only one inline image, so it's a custom emoji by itself as a reply/reaction
 822                    final var source = imageSpans[0].getSource();
 823                    var shortcode = "";
 824                    final var img = html.findChild("img");
 825                    if (img != null) {
 826                        shortcode = img.getAttribute("alt").replaceAll("(^:)|(:$)", "");
 827                    }
 828                    if (source != null && source.length() > 0 && source.substring(0, 4).equals("cid:")) {
 829                        final Cid cid = BobTransfer.cid(Uri.parse(source));
 830                        return new Pair<>(reply.getAttribute("id"), new Reaction(shortcode, cid, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId(), envelopeId));
 831                    }
 832                }
 833            }
 834        }
 835        return null;
 836    }
 837
 838    public Reaction.Aggregated aggregatedReactionsFor(Message m, Function<Reaction, GetThumbnailForCid> thumbnailer) {
 839        Set<Reaction> result = new HashSet<>();
 840        if (getMode() == MODE_MULTI && !m.isPrivateMessage()) {
 841            result.addAll(reactions.get(m.getServerMsgId()));
 842        } else if (m.getStatus() > Message.STATUS_RECEIVED) {
 843            result.addAll(reactions.get(m.getUuid()));
 844        } else {
 845            result.addAll(reactions.get(m.getRemoteMsgId()));
 846        }
 847        result.addAll(m.getReactions());
 848        return Reaction.aggregated(result, thumbnailer);
 849    }
 850
 851    public Thread getThread(String id) {
 852        return threads.get(id);
 853    }
 854
 855    public List<Thread> recentThreads() {
 856        final ArrayList<Thread> recent = new ArrayList<>();
 857        recent.addAll(threads.values());
 858        recent.sort((a, b) -> b.getLastTime() == a.getLastTime() ? 0 : (b.getLastTime() > a.getLastTime() ? 1 : -1));
 859        return recent.size() < 5 ? recent : recent.subList(0, 5);
 860    }
 861
 862    @Override
 863    public boolean isBlocked() {
 864        return getContact().isBlocked();
 865    }
 866
 867    @Override
 868    public boolean isDomainBlocked() {
 869        return getContact().isDomainBlocked();
 870    }
 871
 872    @Override
 873    @NonNull
 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.of(values.getItem(pos).getValue()).toString(), "@/+"), 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.of(text.toString()).toString(), 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                            final var optionValues = options.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2387                            values.addAll(field.getValues());
2388                            for (final String value : field.getValues()) {
2389                                if (filteredValues.contains(value) || (!open && !optionValues.contains(value))) {
2390                                    values.remove(value);
2391                                }
2392                            }
2393                        }
2394
2395                        SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2396                        for (int i = 0; i < positions.size(); i++) {
2397                            if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2398                        }
2399                        field.setValues(values);
2400
2401                        if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2402                    });
2403                    search("");
2404                }
2405
2406                @Override
2407                public void afterTextChanged(Editable s) {
2408                    if (!multi && open) field.setValues(List.of(s.toString()));
2409                    search(s.toString());
2410                }
2411
2412                @Override
2413                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2414
2415                @Override
2416                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2417
2418                protected void search(String s) {
2419                    List<Option> filteredOptions;
2420                    final String q = s.replaceAll("\\W", "").toLowerCase();
2421                    if (q == null || q.equals("")) {
2422                        filteredOptions = options;
2423                    } else {
2424                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2425                    }
2426                    filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2427                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2428                    binding.list.setAdapter(adapter);
2429
2430                    for (final String value : field.getValues()) {
2431                        int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2432                        if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2433                    }
2434                }
2435            }
2436
2437            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2438                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2439                    super(binding);
2440                    binding.open.addTextChangedListener(this);
2441                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2442                        @Override
2443                        public View getView(int position, View convertView, ViewGroup parent) {
2444                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2445                            v.setId(position);
2446                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2447                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2448                            return v;
2449                        }
2450                    };
2451                }
2452                protected Element mValue = null;
2453                protected ArrayAdapter<Option> options;
2454                protected int textColor = -1;
2455
2456                @Override
2457                public void bind(Item item) {
2458                    Field field = (Field) item;
2459                    setTextOrHide(binding.label, field.getLabel());
2460                    setTextOrHide(binding.desc, field.getDesc());
2461
2462                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2463                    if (field.error != null) {
2464                        binding.desc.setVisibility(View.VISIBLE);
2465                        binding.desc.setText(field.error);
2466                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2467                    } else {
2468                        binding.desc.setTextColor(textColor);
2469                    }
2470
2471                    mValue = field.getValue();
2472
2473                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2474                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2475                    binding.open.setText(mValue.getContent());
2476                    setupInputType(field.el, binding.open, null);
2477
2478                    options.clear();
2479                    List<Option> theOptions = field.getOptions();
2480                    options.addAll(theOptions);
2481
2482                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2483                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2484                    float maxColumnWidth = theOptions.stream().map((x) ->
2485                        StaticLayout.getDesiredWidth(x.toString(), paint)
2486                    ).max(Float::compare).orElse(new Float(0.0));
2487                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2488                        binding.radios.setNumColumns(theOptions.size());
2489                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2490                        binding.radios.setNumColumns(theOptions.size() / 2);
2491                    } else {
2492                        binding.radios.setNumColumns(1);
2493                    }
2494                    binding.radios.setAdapter(options);
2495                }
2496
2497                @Override
2498                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2499                    if (mValue == null) return;
2500
2501                    if (isChecked) {
2502                        mValue.setContent(options.getItem(radio.getId()).getValue());
2503                        binding.open.setText(mValue.getContent());
2504                    }
2505                    options.notifyDataSetChanged();
2506                }
2507
2508                @Override
2509                public void afterTextChanged(Editable s) {
2510                    if (mValue == null) return;
2511
2512                    mValue.setContent(s.toString());
2513                    options.notifyDataSetChanged();
2514                }
2515
2516                @Override
2517                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2518
2519                @Override
2520                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2521            }
2522
2523            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2524                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2525                    super(binding);
2526                    binding.spinner.setOnItemSelectedListener(this);
2527                }
2528                protected Element mValue = null;
2529
2530                @Override
2531                public void bind(Item item) {
2532                    Field field = (Field) item;
2533                    setTextOrHide(binding.label, field.getLabel());
2534                    binding.spinner.setPrompt(field.getLabel().or(""));
2535                    setTextOrHide(binding.desc, field.getDesc());
2536
2537                    mValue = field.getValue();
2538
2539                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2540                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2541                    options.addAll(field.getOptions());
2542
2543                    binding.spinner.setAdapter(options);
2544                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2545                }
2546
2547                @Override
2548                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2549                    Option o = (Option) parent.getItemAtPosition(pos);
2550                    if (mValue == null) return;
2551
2552                    mValue.setContent(o == null ? "" : o.getValue());
2553                }
2554
2555                @Override
2556                public void onNothingSelected(AdapterView<?> parent) {
2557                    mValue.setContent("");
2558                }
2559            }
2560
2561            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2562                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2563                    super(binding);
2564                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2565                        protected int height = 0;
2566
2567                        @Override
2568                        public View getView(int position, View convertView, ViewGroup parent) {
2569                            Button v = (Button) super.getView(position, convertView, parent);
2570                            v.setOnClickListener((view) -> {
2571                                mValue.setContent(getItem(position).getValue());
2572                                execute();
2573                                loading = true;
2574                            });
2575
2576                            final SVG icon = getItem(position).getIcon();
2577                            if (icon != null) {
2578                                 final Element iconEl = getItem(position).getIconEl();
2579                                 if (height < 1) {
2580                                     v.measure(0, 0);
2581                                     height = v.getMeasuredHeight();
2582                                 }
2583                                 if (height < 1) return v;
2584                                 if (mediaSelector) {
2585                                     final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2586                                     if (d != null) {
2587                                         final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2588                                         d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2589                                     }
2590                                     v.setCompoundDrawables(null, d, null, null);
2591                                 } else {
2592                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2593                                 }
2594                            }
2595
2596                            return v;
2597                        }
2598                    };
2599                }
2600                protected Element mValue = null;
2601                protected ArrayAdapter<Option> options;
2602                protected Option defaultOption = null;
2603                protected boolean mediaSelector = false;
2604                protected int textColor = -1;
2605
2606                @Override
2607                public void bind(Item item) {
2608                    Field field = (Field) item;
2609                    setTextOrHide(binding.label, field.getLabel());
2610                    setTextOrHide(binding.desc, field.getDesc());
2611
2612                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2613                    if (field.error != null) {
2614                        binding.desc.setVisibility(View.VISIBLE);
2615                        binding.desc.setText(field.error);
2616                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2617                    } else {
2618                        binding.desc.setTextColor(textColor);
2619                    }
2620
2621                    mValue = field.getValue();
2622                    mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2623
2624                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2625                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2626                    binding.openButton.setOnClickListener((view) -> {
2627                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2628                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2629                        builder.setPositiveButton(R.string.action_execute, null);
2630                        if (field.getDesc().isPresent()) {
2631                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2632                        }
2633                        dialogBinding.inputEditText.requestFocus();
2634                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2635                        builder.setView(dialogBinding.getRoot());
2636                        builder.setNegativeButton(R.string.cancel, null);
2637                        final AlertDialog dialog = builder.create();
2638                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2639                        dialog.show();
2640                        View.OnClickListener clickListener = v -> {
2641                            String value = dialogBinding.inputEditText.getText().toString();
2642                            mValue.setContent(value);
2643                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2644                            dialog.dismiss();
2645                            execute();
2646                            loading = true;
2647                        };
2648                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2649                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2650                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2651                            dialog.dismiss();
2652                        }));
2653                        dialog.setCanceledOnTouchOutside(false);
2654                        dialog.setOnDismissListener(dialog1 -> {
2655                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2656                        });
2657                    });
2658
2659                    options.clear();
2660                    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();
2661
2662                    defaultOption = null;
2663                    for (Option option : theOptions) {
2664                        if (option.getValue().equals(mValue.getContent())) {
2665                            defaultOption = option;
2666                            break;
2667                        }
2668                    }
2669                    if (defaultOption == null && !mValue.getContent().equals("")) {
2670                        // Synthesize default option for custom value
2671                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2672                    }
2673                    if (defaultOption == null) {
2674                        binding.defaultButton.setVisibility(View.GONE);
2675                    } else {
2676                        theOptions.remove(defaultOption);
2677                        binding.defaultButton.setVisibility(View.VISIBLE);
2678
2679                        final SVG defaultIcon = defaultOption.getIcon();
2680                        if (defaultIcon != null) {
2681                             DisplayMetrics display = mPager.get().getContext().getResources().getDisplayMetrics();
2682                             int height = (int)(display.heightPixels*display.density/4);
2683                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2684                        }
2685
2686                        binding.defaultButton.setText(defaultOption.toString());
2687                        binding.defaultButton.setOnClickListener((view) -> {
2688                            mValue.setContent(defaultOption.getValue());
2689                            execute();
2690                            loading = true;
2691                        });
2692                    }
2693
2694                    options.addAll(theOptions);
2695                    binding.buttons.setAdapter(options);
2696                }
2697            }
2698
2699            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2700                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2701                    super(binding);
2702                    binding.textinput.addTextChangedListener(this);
2703                }
2704                protected Field field = null;
2705
2706                @Override
2707                public void bind(Item item) {
2708                    field = (Field) item;
2709                    binding.textinputLayout.setHint(field.getLabel().or(""));
2710
2711                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2712                    for (String desc : field.getDesc().asSet()) {
2713                        binding.textinputLayout.setHelperText(desc);
2714                    }
2715
2716                    binding.textinputLayout.setErrorEnabled(field.error != null);
2717                    if (field.error != null) binding.textinputLayout.setError(field.error);
2718
2719                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2720                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2721                    if (suffixLabel == null) {
2722                        binding.textinputLayout.setSuffixText("");
2723                    } else {
2724                        binding.textinputLayout.setSuffixText(suffixLabel);
2725                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2726                    }
2727
2728                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2729                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2730
2731                    binding.textinput.setText(String.join("\n", field.getValues()));
2732                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2733                }
2734
2735                @Override
2736                public void afterTextChanged(Editable s) {
2737                    if (field == null) return;
2738
2739                    field.setValues(List.of(s.toString().split("\n")));
2740                }
2741
2742                @Override
2743                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2744
2745                @Override
2746                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2747            }
2748
2749            class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2750                public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2751                protected Field field = null;
2752
2753                @Override
2754                public void bind(Item item) {
2755                    field = (Field) item;
2756                    setTextOrHide(binding.label, field.getLabel());
2757                    setTextOrHide(binding.desc, field.getDesc());
2758                    final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2759                    final String datatype = validate == null ? null : validate.getAttribute("datatype");
2760                    final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2761                    // NOTE: range also implies open, so we don't have to be bound by the options strictly
2762                    // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2763                    Float min = null;
2764                    try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2765                    Float max = null;
2766                    try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max"));  } catch (NumberFormatException e) { }
2767
2768                    List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2769                    Collections.sort(options);
2770                    if (options.size() > 0) {
2771                        // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2772                        if (min == null) min = options.get(0);
2773                        if (max == null) max = options.get(options.size()-1);
2774                    }
2775
2776                    if (field.getValues().size() > 0) {
2777                        final var val = Float.valueOf(field.getValue().getContent());
2778                        if ((min == null || val >= min) && (max == null || val <= max)) {
2779                            binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2780                        } else {
2781                            binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2782                        }
2783                    } else {
2784                        binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2785                    }
2786                    binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2787                    binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2788                    if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2789                        binding.slider.setStepSize(1);
2790                    } else {
2791                        binding.slider.setStepSize(0);
2792                    }
2793
2794                    if (options.size() > 0) {
2795                        float step = -1;
2796                        Float prev = null;
2797                        for (final Float option : options) {
2798                            if (prev != null) {
2799                                float nextStep = option - prev;
2800                                if (step > 0 && step != nextStep) {
2801                                    step = -1;
2802                                    break;
2803                                }
2804                                step = nextStep;
2805                            }
2806                            prev = option;
2807                        }
2808                        if (step > 0) binding.slider.setStepSize(step);
2809                    }
2810
2811                    binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2812                        field.setValues(List.of(new DecimalFormat().format(value)));
2813                    });
2814                }
2815            }
2816
2817            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2818                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2819                protected String boundUrl = "";
2820
2821                @Override
2822                public void bind(Item oob) {
2823                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2824                    binding.webview.getSettings().setJavaScriptEnabled(true);
2825                    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");
2826                    binding.webview.getSettings().setDatabaseEnabled(true);
2827                    binding.webview.getSettings().setDomStorageEnabled(true);
2828                    binding.webview.setWebChromeClient(new WebChromeClient() {
2829                        @Override
2830                        public void onProgressChanged(WebView view, int newProgress) {
2831                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2832                            binding.progressbar.setProgress(newProgress);
2833                        }
2834                    });
2835                    binding.webview.setWebViewClient(new WebViewClient() {
2836                        @Override
2837                        public void onPageFinished(WebView view, String url) {
2838                            super.onPageFinished(view, url);
2839                            mTitle = view.getTitle();
2840                            ConversationPagerAdapter.this.notifyDataSetChanged();
2841                        }
2842                    });
2843                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2844                    if (!boundUrl.equals(url)) {
2845                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2846                        binding.webview.loadUrl(url);
2847                        boundUrl = url;
2848                    }
2849                }
2850
2851                class JsObject {
2852                    @JavascriptInterface
2853                    public void execute() { execute("execute"); }
2854
2855                    @JavascriptInterface
2856                    public void execute(String action) {
2857                        getView().post(() -> {
2858                            actionToWebview = null;
2859                            if(CommandSession.this.execute(action)) {
2860                                removeSession(CommandSession.this);
2861                            }
2862                        });
2863                    }
2864
2865                    @JavascriptInterface
2866                    public void preventDefault() {
2867                        actionToWebview = binding.webview;
2868                    }
2869                }
2870            }
2871
2872            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2873                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2874
2875                @Override
2876                public void bind(Item item) {
2877                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2878                }
2879            }
2880
2881            class Item {
2882                protected Element el;
2883                protected int viewType;
2884                protected String error = null;
2885
2886                Item(Element el, int viewType) {
2887                    this.el = el;
2888                    this.viewType = viewType;
2889                }
2890
2891                public boolean validate() {
2892                    error = null;
2893                    return true;
2894                }
2895            }
2896
2897            class Field extends Item {
2898                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2899
2900                @Override
2901                public boolean validate() {
2902                    if (!super.validate()) return false;
2903                    if (el.findChild("required", "jabber:x:data") == null) return true;
2904                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2905
2906                    error = "this value is required";
2907                    return false;
2908                }
2909
2910                public String getVar() {
2911                    return el.getAttribute("var");
2912                }
2913
2914                public Optional<String> getType() {
2915                    return Optional.fromNullable(el.getAttribute("type"));
2916                }
2917
2918                public Optional<String> getLabel() {
2919                    String label = el.getAttribute("label");
2920                    if (label == null) label = getVar();
2921                    return Optional.fromNullable(label);
2922                }
2923
2924                public Optional<String> getDesc() {
2925                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2926                }
2927
2928                public Element getValue() {
2929                    Element value = el.findChild("value", "jabber:x:data");
2930                    if (value == null) {
2931                        value = el.addChild("value", "jabber:x:data");
2932                    }
2933                    return value;
2934                }
2935
2936                public void setValues(Collection<String> values) {
2937                    for(Element child : el.getChildren()) {
2938                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2939                            el.removeChild(child);
2940                        }
2941                    }
2942
2943                    for (String value : values) {
2944                        el.addChild("value", "jabber:x:data").setContent(value);
2945                    }
2946                }
2947
2948                public List<String> getValues() {
2949                    List<String> values = new ArrayList<>();
2950                    for(Element child : el.getChildren()) {
2951                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2952                            values.add(child.getContent());
2953                        }
2954                    }
2955                    return values;
2956                }
2957
2958                public List<Option> getOptions() {
2959                    return Option.forField(el);
2960                }
2961            }
2962
2963            class Cell extends Item {
2964                protected Field reported;
2965
2966                Cell(Field reported, Element item) {
2967                    super(item, TYPE_RESULT_CELL);
2968                    this.reported = reported;
2969                }
2970            }
2971
2972            protected Field mkField(Element el) {
2973                int viewType = -1;
2974
2975                String formType = responseElement.getAttribute("type");
2976                if (formType != null) {
2977                    String fieldType = el.getAttribute("type");
2978                    if (fieldType == null) fieldType = "text-single";
2979
2980                    if (formType.equals("result") || fieldType.equals("fixed")) {
2981                        viewType = TYPE_RESULT_FIELD;
2982                    } else if (formType.equals("form")) {
2983                        final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2984                        final String datatype = validate == null ? null : validate.getAttribute("datatype");
2985                        final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2986                        if (fieldType.equals("boolean")) {
2987                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2988                                viewType = TYPE_BUTTON_GRID_FIELD;
2989                            } else {
2990                                viewType = TYPE_CHECKBOX_FIELD;
2991                            }
2992                        } else if (
2993                            range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2994                                "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2995                                "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2996                            )
2997                        ) {
2998                            // has a range and is numeric, use a slider
2999                            viewType = TYPE_SLIDER_FIELD;
3000                        } else if (fieldType.equals("list-single")) {
3001                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
3002                                viewType = TYPE_BUTTON_GRID_FIELD;
3003                            } else if (Option.forField(el).size() > 9) {
3004                                viewType = TYPE_SEARCH_LIST_FIELD;
3005                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
3006                                viewType = TYPE_RADIO_EDIT_FIELD;
3007                            } else {
3008                                viewType = TYPE_SPINNER_FIELD;
3009                            }
3010                        } else if (fieldType.equals("list-multi")) {
3011                            viewType = TYPE_SEARCH_LIST_FIELD;
3012                        } else {
3013                            viewType = TYPE_TEXT_FIELD;
3014                        }
3015                    }
3016
3017                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
3018                }
3019
3020                return null;
3021            }
3022
3023            protected Item mkItem(Element el, int pos) {
3024                int viewType = TYPE_ERROR;
3025
3026                if (response != null && response.getType() == Iq.Type.RESULT) {
3027                    if (el.getName().equals("note")) {
3028                        viewType = TYPE_NOTE;
3029                    } else if (el.getNamespace().equals("jabber:x:oob")) {
3030                        viewType = TYPE_WEB;
3031                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
3032                        viewType = TYPE_NOTE;
3033                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
3034                        Field field = mkField(el);
3035                        if (field != null) {
3036                            items.put(pos, field);
3037                            return field;
3038                        }
3039                    }
3040                }
3041
3042                Item item = new Item(el, viewType);
3043                items.put(pos, item);
3044                return item;
3045            }
3046
3047            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
3048                protected Context ctx;
3049
3050                public ActionsAdapter(Context ctx) {
3051                    super(ctx, R.layout.simple_list_item);
3052                    this.ctx = ctx;
3053                }
3054
3055                @Override
3056                public View getView(int position, View convertView, ViewGroup parent) {
3057                    View v = super.getView(position, convertView, parent);
3058                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
3059                    tv.setGravity(Gravity.CENTER);
3060                    tv.setText(getItem(position).second);
3061                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
3062                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
3063                    final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
3064                    tv.setTextColor(colors.getOnAccent());
3065                    tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
3066                    return v;
3067                }
3068
3069                public int getPosition(String s) {
3070                    for(int i = 0; i < getCount(); i++) {
3071                        if (getItem(i).first.equals(s)) return i;
3072                    }
3073                    return -1;
3074                }
3075
3076                public int countProceed() {
3077                    int count = 0;
3078                    for(int i = 0; i < getCount(); i++) {
3079                        if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
3080                    }
3081                    return count;
3082                }
3083
3084                public int countExceptCancel() {
3085                    int count = 0;
3086                    for(int i = 0; i < getCount(); i++) {
3087                        if (!getItem(i).first.equals("cancel")) count++;
3088                    }
3089                    return count;
3090                }
3091
3092                public void clearProceed() {
3093                    Pair<String,String> cancelItem = null;
3094                    Pair<String,String> prevItem = null;
3095                    for(int i = 0; i < getCount(); i++) {
3096                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
3097                        if (getItem(i).first.equals("prev")) prevItem = getItem(i);
3098                    }
3099                    clear();
3100                    if (cancelItem != null) add(cancelItem);
3101                    if (prevItem != null) add(prevItem);
3102                }
3103            }
3104
3105            final int TYPE_ERROR = 1;
3106            final int TYPE_NOTE = 2;
3107            final int TYPE_WEB = 3;
3108            final int TYPE_RESULT_FIELD = 4;
3109            final int TYPE_TEXT_FIELD = 5;
3110            final int TYPE_CHECKBOX_FIELD = 6;
3111            final int TYPE_SPINNER_FIELD = 7;
3112            final int TYPE_RADIO_EDIT_FIELD = 8;
3113            final int TYPE_RESULT_CELL = 9;
3114            final int TYPE_PROGRESSBAR = 10;
3115            final int TYPE_SEARCH_LIST_FIELD = 11;
3116            final int TYPE_ITEM_CARD = 12;
3117            final int TYPE_BUTTON_GRID_FIELD = 13;
3118            final int TYPE_SLIDER_FIELD = 14;
3119
3120            protected boolean executing = false;
3121            protected boolean loading = false;
3122            protected boolean loadingHasBeenLong = false;
3123            protected Timer loadingTimer = new Timer();
3124            protected String mTitle;
3125            protected String mNode;
3126            protected CommandPageBinding mBinding = null;
3127            protected Iq response = null;
3128            protected Element responseElement = null;
3129            protected boolean expectingRemoval = false;
3130            protected List<Field> reported = null;
3131            protected SparseArray<Item> items = new SparseArray<>();
3132            protected XmppConnectionService xmppConnectionService;
3133            protected ActionsAdapter actionsAdapter = null;
3134            protected GridLayoutManager layoutManager;
3135            protected WebView actionToWebview = null;
3136            protected int fillableFieldCount = 0;
3137            protected Iq pendingResponsePacket = null;
3138            protected boolean waitingForRefresh = false;
3139
3140            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3141                loading();
3142                mTitle = title;
3143                mNode = node;
3144                this.xmppConnectionService = xmppConnectionService;
3145                if (mPager.get() != null) setupLayoutManager(mPager.get().getContext());
3146            }
3147
3148            public String getTitle() {
3149                return mTitle;
3150            }
3151
3152            public String getNode() {
3153                return mNode;
3154            }
3155
3156            public void updateWithResponse(final Iq iq) {
3157                if (getView() != null && getView().isAttachedToWindow()) {
3158                    getView().post(() -> updateWithResponseUiThread(iq));
3159                } else {
3160                    pendingResponsePacket = iq;
3161                }
3162            }
3163
3164            protected void updateWithResponseUiThread(final Iq iq) {
3165                Timer oldTimer = this.loadingTimer;
3166                this.loadingTimer = new Timer();
3167                oldTimer.cancel();
3168                this.executing = false;
3169                this.loading = false;
3170                this.loadingHasBeenLong = false;
3171                this.responseElement = null;
3172                this.fillableFieldCount = 0;
3173                this.reported = null;
3174                this.response = iq;
3175                this.items.clear();
3176                this.actionsAdapter.clear();
3177                layoutManager.setSpanCount(1);
3178
3179                boolean actionsCleared = false;
3180                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3181                if (iq.getType() == Iq.Type.RESULT && command != null) {
3182                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3183                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()));
3184                    }
3185
3186                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3187                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3188                    }
3189
3190                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3191                    if (actions != null) {
3192                        for (Element action : actions.getChildren()) {
3193                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3194                            if ("execute".equals(action.getName())) continue;
3195
3196                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3197                        }
3198                    }
3199
3200                    for (Element el : command.getChildren()) {
3201                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3202                            Data form = Data.parse(el);
3203                            String title = form.getTitle();
3204                            if (title != null) {
3205                                mTitle = title;
3206                                ConversationPagerAdapter.this.notifyDataSetChanged();
3207                            }
3208
3209                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3210                                this.responseElement = el;
3211                                setupReported(el.findChild("reported", "jabber:x:data"));
3212                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3213                            }
3214
3215                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3216                            if (actionList != null) {
3217                                actionsAdapter.clear();
3218
3219                                for (Option action : actionList.getOptions()) {
3220                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3221                                }
3222                            }
3223
3224                            eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3225                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3226                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3227                                   final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3228                                   final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3229                                    fillableField = range == null ? field : null;
3230                                    fillableFieldCount++;
3231                                }
3232                            }
3233
3234                            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))) {
3235                                actionsCleared = true;
3236                                actionsAdapter.clearProceed();
3237                            }
3238                            break;
3239                        }
3240                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3241                            String url = el.findChildContent("url", "jabber:x:oob");
3242                            if (url != null) {
3243                                String scheme = Uri.parse(url).getScheme();
3244                                if (scheme.equals("http") || scheme.equals("https")) {
3245                                    this.responseElement = el;
3246                                    break;
3247                                }
3248                                if (scheme.equals("xmpp")) {
3249                                    expectingRemoval = true;
3250                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3251                                    intent.setAction(Intent.ACTION_VIEW);
3252                                    intent.setData(Uri.parse(url));
3253                                    getView().getContext().startActivity(intent);
3254                                    break;
3255                                }
3256                            }
3257                        }
3258                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3259                            this.responseElement = el;
3260                            break;
3261                        }
3262                    }
3263
3264                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3265                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3266                            if (xmppConnectionService.isOnboarding()) {
3267                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3268                                    xmppConnectionService.deleteAccount(getAccount());
3269                                } else {
3270                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3271                                        removeSession(this);
3272                                        return;
3273                                    } else {
3274                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3275                                        xmppConnectionService.deleteAccount(getAccount());
3276                                    }
3277                                }
3278                            }
3279                            xmppConnectionService.archiveConversation(Conversation.this);
3280                        }
3281
3282                        expectingRemoval = true;
3283                        removeSession(this);
3284                        return;
3285                    }
3286
3287                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3288                        // No actions have been given, but we are not done?
3289                        // This is probably a spec violation, but we should do *something*
3290                        actionsAdapter.add(Pair.create("execute", "execute"));
3291                    }
3292
3293                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3294                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3295                            actionsAdapter.add(Pair.create("close", "close"));
3296                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3297                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3298                        }
3299                    }
3300                }
3301
3302                if (actionsAdapter.isEmpty()) {
3303                    actionsAdapter.add(Pair.create("close", "close"));
3304                }
3305
3306                actionsAdapter.sort((x, y) -> {
3307                    if (x.first.equals("cancel")) return -1;
3308                    if (y.first.equals("cancel")) return 1;
3309                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3310                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3311                    return 0;
3312                });
3313
3314                Data dataForm = null;
3315                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3316                if (mNode.equals("jabber:iq:register") &&
3317                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
3318                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3319
3320
3321                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3322                    execute();
3323                }
3324                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3325                notifyDataSetChanged();
3326            }
3327
3328            protected void setupReported(Element el) {
3329                if (el == null) {
3330                    reported = null;
3331                    return;
3332                }
3333
3334                reported = new ArrayList<>();
3335                for (Element fieldEl : el.getChildren()) {
3336                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3337                    reported.add(mkField(fieldEl));
3338                }
3339            }
3340
3341            @Override
3342            public int getItemCount() {
3343                if (loading) return 1;
3344                if (response == null) return 0;
3345                if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3346                    int i = 0;
3347                    for (Element el : responseElement.getChildren()) {
3348                        if (!el.getNamespace().equals("jabber:x:data")) continue;
3349                        if (el.getName().equals("title")) continue;
3350                        if (el.getName().equals("field")) {
3351                            String type = el.getAttribute("type");
3352                            if (type != null && type.equals("hidden")) continue;
3353                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3354                        }
3355
3356                        if (el.getName().equals("reported") || el.getName().equals("item")) {
3357                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3358                                if (el.getName().equals("reported")) continue;
3359                                i += 1;
3360                            } else {
3361                                if (reported != null) i += reported.size();
3362                            }
3363                            continue;
3364                        }
3365
3366                        i++;
3367                    }
3368                    return i;
3369                }
3370                return 1;
3371            }
3372
3373            public Item getItem(int position) {
3374                if (loading) return new Item(null, TYPE_PROGRESSBAR);
3375                if (items.get(position) != null) return items.get(position);
3376                if (response == null) return null;
3377
3378                if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3379                    if (responseElement.getNamespace().equals("jabber:x:data")) {
3380                        int i = 0;
3381                        for (Element el : responseElement.getChildren()) {
3382                            if (!el.getNamespace().equals("jabber:x:data")) continue;
3383                            if (el.getName().equals("title")) continue;
3384                            if (el.getName().equals("field")) {
3385                                String type = el.getAttribute("type");
3386                                if (type != null && type.equals("hidden")) continue;
3387                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3388                            }
3389
3390                            if (el.getName().equals("reported") || el.getName().equals("item")) {
3391                                Cell cell = null;
3392
3393                                if (reported != null) {
3394                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3395                                        if (el.getName().equals("reported")) continue;
3396                                        if (i == position) {
3397                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
3398                                            return items.get(position);
3399                                        }
3400                                    } else {
3401                                        if (reported.size() > position - i) {
3402                                            Field reportedField = reported.get(position - i);
3403                                            Element itemField = null;
3404                                            if (el.getName().equals("item")) {
3405                                                for (Element subel : el.getChildren()) {
3406                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
3407                                                       itemField = subel;
3408                                                       break;
3409                                                    }
3410                                                }
3411                                            }
3412                                            cell = new Cell(reportedField, itemField);
3413                                        } else {
3414                                            i += reported.size();
3415                                            continue;
3416                                        }
3417                                    }
3418                                }
3419
3420                                if (cell != null) {
3421                                    items.put(position, cell);
3422                                    return cell;
3423                                }
3424                            }
3425
3426                            if (i < position) {
3427                                i++;
3428                                continue;
3429                            }
3430
3431                            return mkItem(el, position);
3432                        }
3433                    }
3434                }
3435
3436                return mkItem(responseElement == null ? response : responseElement, position);
3437            }
3438
3439            @Override
3440            public int getItemViewType(int position) {
3441                return getItem(position).viewType;
3442            }
3443
3444            @Override
3445            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3446                switch(viewType) {
3447                    case TYPE_ERROR: {
3448                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3449                        return new ErrorViewHolder(binding);
3450                    }
3451                    case TYPE_NOTE: {
3452                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3453                        return new NoteViewHolder(binding);
3454                    }
3455                    case TYPE_WEB: {
3456                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3457                        return new WebViewHolder(binding);
3458                    }
3459                    case TYPE_RESULT_FIELD: {
3460                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3461                        return new ResultFieldViewHolder(binding);
3462                    }
3463                    case TYPE_RESULT_CELL: {
3464                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3465                        return new ResultCellViewHolder(binding);
3466                    }
3467                    case TYPE_ITEM_CARD: {
3468                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3469                        return new ItemCardViewHolder(binding);
3470                    }
3471                    case TYPE_CHECKBOX_FIELD: {
3472                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3473                        return new CheckboxFieldViewHolder(binding);
3474                    }
3475                    case TYPE_SEARCH_LIST_FIELD: {
3476                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3477                        return new SearchListFieldViewHolder(binding);
3478                    }
3479                    case TYPE_RADIO_EDIT_FIELD: {
3480                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3481                        return new RadioEditFieldViewHolder(binding);
3482                    }
3483                    case TYPE_SPINNER_FIELD: {
3484                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3485                        return new SpinnerFieldViewHolder(binding);
3486                    }
3487                    case TYPE_BUTTON_GRID_FIELD: {
3488                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3489                        return new ButtonGridFieldViewHolder(binding);
3490                    }
3491                    case TYPE_TEXT_FIELD: {
3492                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3493                        return new TextFieldViewHolder(binding);
3494                    }
3495                    case TYPE_SLIDER_FIELD: {
3496                        CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3497                        return new SliderFieldViewHolder(binding);
3498                    }
3499                    case TYPE_PROGRESSBAR: {
3500                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3501                        return new ProgressBarViewHolder(binding);
3502                    }
3503                    default:
3504                        if (expectingRemoval) {
3505                            CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3506                            return new NoteViewHolder(binding);
3507                        }
3508
3509                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3510                }
3511            }
3512
3513            @Override
3514            public void onBindViewHolder(ViewHolder viewHolder, int position) {
3515                viewHolder.bind(getItem(position));
3516            }
3517
3518            public View getView() {
3519                if (mBinding == null) return null;
3520                return mBinding.getRoot();
3521            }
3522
3523            public boolean validate() {
3524                int count = getItemCount();
3525                boolean isValid = true;
3526                for (int i = 0; i < count; i++) {
3527                    boolean oneIsValid = getItem(i).validate();
3528                    isValid = isValid && oneIsValid;
3529                }
3530                notifyDataSetChanged();
3531                return isValid;
3532            }
3533
3534            public boolean execute() {
3535                return execute("execute");
3536            }
3537
3538            public boolean execute(int actionPosition) {
3539                return execute(actionsAdapter.getItem(actionPosition).first);
3540            }
3541
3542            public synchronized boolean execute(String action) {
3543                if (!"cancel".equals(action) && executing) {
3544                    loadingHasBeenLong = true;
3545                    notifyDataSetChanged();
3546                    return false;
3547                }
3548                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3549
3550                if (response == null) return true;
3551                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3552                if (command == null) return true;
3553                String status = command.getAttribute("status");
3554                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3555
3556                if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3557                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3558                    return false;
3559                }
3560
3561                final var packet = new Iq(Iq.Type.SET);
3562                packet.setTo(response.getFrom());
3563                final Element c = packet.addChild("command", Namespace.COMMANDS);
3564                c.setAttribute("node", mNode);
3565                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3566
3567                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3568                if (!action.equals("cancel") &&
3569                    !action.equals("prev") &&
3570                    responseElement != null &&
3571                    responseElement.getName().equals("x") &&
3572                    responseElement.getNamespace().equals("jabber:x:data") &&
3573                    formType != null && formType.equals("form")) {
3574
3575                    Data form = Data.parse(responseElement);
3576                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3577                    if (actionList != null) {
3578                        actionList.setValue(action);
3579                        c.setAttribute("action", "execute");
3580                    }
3581
3582                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3583                        if (form.getValue("gateway-jid") == null) {
3584                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3585                        } else {
3586                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3587                        }
3588                    }
3589
3590                    responseElement.setAttribute("type", "submit");
3591                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3592                    if (rsm != null) {
3593                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3594                        max.setContent("1000");
3595                        rsm.addChild(max);
3596                    }
3597
3598                    c.addChild(responseElement);
3599                }
3600
3601                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3602
3603                executing = true;
3604                xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3605                    updateWithResponse(iq);
3606                }, 120L);
3607
3608                loading();
3609                return false;
3610            }
3611
3612            public void refresh() {
3613                synchronized(this) {
3614                    if (waitingForRefresh) notifyDataSetChanged();
3615                }
3616            }
3617
3618            protected void loading() {
3619                View v = getView();
3620                try {
3621                    loadingTimer.schedule(new TimerTask() {
3622                        @Override
3623                        public void run() {
3624                            View v2 = getView();
3625                            loading = true;
3626
3627                            try {
3628                                loadingTimer.schedule(new TimerTask() {
3629                                    @Override
3630                                    public void run() {
3631                                        loadingHasBeenLong = true;
3632                                        if (v == null && v2 == null) return;
3633                                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3634                                    }
3635                                }, 3000);
3636                            } catch (final IllegalStateException e) { }
3637
3638                            if (v == null && v2 == null) return;
3639                            (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3640                        }
3641                    }, 500);
3642                } catch (final IllegalStateException e) { }
3643            }
3644
3645            protected GridLayoutManager setupLayoutManager(final Context ctx) {
3646                int spanCount = 1;
3647
3648                if (reported != null) {
3649                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3650                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.get().getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3651                    float tableHeaderWidth = reported.stream().reduce(
3652                        0f,
3653                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3654                        (a, b) -> a + b
3655                    );
3656
3657                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3658                }
3659
3660                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3661                    items.clear();
3662                    notifyDataSetChanged();
3663                }
3664
3665                layoutManager = new GridLayoutManager(ctx, spanCount);
3666                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3667                    @Override
3668                    public int getSpanSize(int position) {
3669                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3670                        return 1;
3671                    }
3672                });
3673                return layoutManager;
3674            }
3675
3676            protected void setBinding(CommandPageBinding b) {
3677                mBinding = b;
3678                // https://stackoverflow.com/a/32350474/8611
3679                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3680                    @Override
3681                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3682                        if(rv.getChildCount() > 0) {
3683                            int[] location = new int[2];
3684                            rv.getLocationOnScreen(location);
3685                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3686                            if (childView instanceof ViewGroup) {
3687                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3688                            }
3689                            int action = e.getAction();
3690                            switch (action) {
3691                                case MotionEvent.ACTION_DOWN:
3692                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3693                                        rv.requestDisallowInterceptTouchEvent(true);
3694                                    }
3695                                case MotionEvent.ACTION_UP:
3696                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3697                                        rv.requestDisallowInterceptTouchEvent(true);
3698                                    }
3699                            }
3700                        }
3701
3702                        return false;
3703                    }
3704
3705                    @Override
3706                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3707
3708                    @Override
3709                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3710                });
3711                mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3712                mBinding.form.setAdapter(this);
3713
3714                if (actionsAdapter == null) {
3715                    actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3716                    actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3717                        @Override
3718                        public void onChanged() {
3719                            if (mBinding == null) return;
3720
3721                            mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3722                        }
3723
3724                        @Override
3725                        public void onInvalidated() {}
3726                    });
3727                }
3728
3729                mBinding.actions.setAdapter(actionsAdapter);
3730                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3731                    if (execute(pos)) {
3732                        removeSession(CommandSession.this);
3733                    }
3734                });
3735
3736                actionsAdapter.notifyDataSetChanged();
3737
3738                if (pendingResponsePacket != null) {
3739                    final var pending = pendingResponsePacket;
3740                    pendingResponsePacket = null;
3741                    updateWithResponseUiThread(pending);
3742                }
3743            }
3744
3745            private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3746               if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image"))  {
3747                   return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3748               } else {
3749                   return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3750               }
3751            }
3752
3753            private Drawable getDrawableForUrl(final String url) {
3754                final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3755                final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3756                final Drawable d = cache.get(url);
3757                if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3758                if (d == null) {
3759                    synchronized (CommandSession.this) {
3760                        waitingForRefresh = true;
3761                    }
3762                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3763                    Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3764                    dummy.setStatus(Message.STATUS_DUMMY);
3765                    dummy.setFileParams(new Message.FileParams(url));
3766                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3767                        if (file == null) {
3768                            dummy.getTransferable().start();
3769                        } else {
3770                            try {
3771                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3772                            } catch (final Exception e) { }
3773                        }
3774                    });
3775                }
3776                return d;
3777            }
3778
3779            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3780                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3781                setBinding(binding);
3782                return binding.getRoot();
3783            }
3784
3785            // https://stackoverflow.com/a/36037991/8611
3786            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3787                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3788                    View child = viewGroup.getChildAt(i);
3789                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3790                        View foundView = findViewAt((ViewGroup) child, x, y);
3791                        if (foundView != null && foundView.isShown()) {
3792                            return foundView;
3793                        }
3794                    } else {
3795                        int[] location = new int[2];
3796                        child.getLocationOnScreen(location);
3797                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3798                        if (rect.contains((int)x, (int)y)) {
3799                            return child;
3800                        }
3801                    }
3802                }
3803
3804                return null;
3805            }
3806        }
3807
3808        class MucConfigSession extends CommandSession {
3809            MucConfigSession(XmppConnectionService xmppConnectionService) {
3810                super("Configure Channel", null, xmppConnectionService);
3811            }
3812
3813            @Override
3814            protected void updateWithResponseUiThread(final Iq iq) {
3815                Timer oldTimer = this.loadingTimer;
3816                this.loadingTimer = new Timer();
3817                oldTimer.cancel();
3818                this.executing = false;
3819                this.loading = false;
3820                this.loadingHasBeenLong = false;
3821                this.responseElement = null;
3822                this.fillableFieldCount = 0;
3823                this.reported = null;
3824                this.response = iq;
3825                this.items.clear();
3826                this.actionsAdapter.clear();
3827                layoutManager.setSpanCount(1);
3828
3829                final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3830                if (iq.getType() == Iq.Type.RESULT && query != null) {
3831                    final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3832                    final String title = form.getTitle();
3833                    if (title != null) {
3834                        mTitle = title;
3835                        ConversationPagerAdapter.this.notifyDataSetChanged();
3836                    }
3837
3838                    this.responseElement = form;
3839                    setupReported(form.findChild("reported", "jabber:x:data"));
3840                    if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3841
3842                    if (actionsAdapter.countExceptCancel() < 1) {
3843                        actionsAdapter.add(Pair.create("save", "Save"));
3844                    }
3845
3846                    if (actionsAdapter.getPosition("cancel") < 0) {
3847                        actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3848                    }
3849                } else if (iq.getType() == Iq.Type.RESULT) {
3850                    expectingRemoval = true;
3851                    removeSession(this);
3852                    return;
3853                } else {
3854                    actionsAdapter.add(Pair.create("close", "close"));
3855                }
3856
3857                notifyDataSetChanged();
3858            }
3859
3860            @Override
3861            public synchronized boolean execute(String action) {
3862                if ("cancel".equals(action)) {
3863                    final var packet = new Iq(Iq.Type.SET);
3864                    packet.setTo(response.getFrom());
3865                    final Element form = packet
3866                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3867                        .addChild("x", "jabber:x:data");
3868                    form.setAttribute("type", "cancel");
3869                    xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3870                    return true;
3871                }
3872
3873                if (!"save".equals(action)) return true;
3874
3875                final var packet = new Iq(Iq.Type.SET);
3876                packet.setTo(response.getFrom());
3877
3878                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3879                if (responseElement != null &&
3880                    responseElement.getName().equals("x") &&
3881                    responseElement.getNamespace().equals("jabber:x:data") &&
3882                    formType != null && formType.equals("form")) {
3883
3884                    responseElement.setAttribute("type", "submit");
3885                    packet
3886                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3887                        .addChild(responseElement);
3888                }
3889
3890                executing = true;
3891                xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3892                    updateWithResponse(iq);
3893                }, 120L);
3894
3895                loading();
3896
3897                return false;
3898            }
3899        }
3900    }
3901
3902    public static class Thread {
3903        protected Message subject = null;
3904        protected Message first = null;
3905        protected Message last = null;
3906        protected final String threadId;
3907
3908        protected Thread(final String threadId) {
3909            this.threadId = threadId;
3910        }
3911
3912        public String getThreadId() {
3913            return threadId;
3914        }
3915
3916        public String getSubject() {
3917            if (subject == null) return null;
3918
3919            return subject.getSubject();
3920        }
3921
3922        public String getDisplay() {
3923            final String s = getSubject();
3924            if (s != null) return s;
3925
3926            if (first != null) {
3927                return first.getBody();
3928            }
3929
3930            return "";
3931        }
3932
3933        public long getLastTime() {
3934            if (last == null) return 0;
3935
3936            return last.getTimeSent();
3937        }
3938    }
3939}