Conversation.java

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