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