Conversation.java

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