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