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                page1 = null;
1644                page2 = null;
1645                return;
1646            }
1647            if (sessions != null) show();
1648
1649            if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1650            if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1651            if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1652                page1 = null;
1653                page2 = null;
1654            }
1655            if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1656            if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1657            if (page1 == null || page2 == null) {
1658                throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1659            }
1660            pager.removeView(page1);
1661            pager.removeView(page2);
1662            pager.setAdapter(this);
1663            tabs.setupWithViewPager(mPager);
1664            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1665
1666            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1667                public void onPageScrollStateChanged(int state) { }
1668                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1669
1670                public void onPageSelected(int position) {
1671                    setCurrentTab(position);
1672                }
1673            });
1674        }
1675
1676        public void show() {
1677            if (sessions == null) {
1678                sessions = new ArrayList<>();
1679                notifyDataSetChanged();
1680            }
1681            if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1682        }
1683
1684        public void hide() {
1685            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1686            if (mPager != null) mPager.setCurrentItem(0);
1687            if (mTabs != null) mTabs.setVisibility(View.GONE);
1688            sessions = null;
1689            notifyDataSetChanged();
1690        }
1691
1692        public void refreshSessions() {
1693            if (sessions == null) return;
1694
1695            for (ConversationPage session : sessions) {
1696                session.refresh();
1697            }
1698        }
1699
1700        public void webxdcRealtimeData(final Element thread, final String base64) {
1701            if (sessions == null) return;
1702
1703            for (ConversationPage session : sessions) {
1704                if (session instanceof WebxdcPage) {
1705                    if (((WebxdcPage) session).threadMatches(thread)) {
1706                        ((WebxdcPage) session).realtimeData(base64);
1707                    }
1708                }
1709            }
1710        }
1711
1712        public void startWebxdc(WebxdcPage page) {
1713            show();
1714            sessions.add(page);
1715            notifyDataSetChanged();
1716            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1717        }
1718
1719        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1720            show();
1721            CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1722
1723            final var packet = new Iq(Iq.Type.SET);
1724            packet.setTo(command.getAttributeAsJid("jid"));
1725            final Element c = packet.addChild("command", Namespace.COMMANDS);
1726            c.setAttribute("node", command.getAttribute("node"));
1727            c.setAttribute("action", "execute");
1728
1729            final TimerTask task = new TimerTask() {
1730                @Override
1731                public void run() {
1732                    if (getAccount().getStatus() != Account.State.ONLINE) {
1733                        final TimerTask self = this;
1734                        new Timer().schedule(new TimerTask() {
1735                            @Override
1736                            public void run() {
1737                                self.run();
1738                            }
1739                        }, 1000);
1740                    } else {
1741                        xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
1742                            session.updateWithResponse(iq);
1743                        }, 120L);
1744                    }
1745                }
1746            };
1747
1748            if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1749                new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1750                    if (signedData != null && signature != null) {
1751                        c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1752                        c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1753                    }
1754
1755                    task.run();
1756                }).checkLicense();
1757            } else {
1758                task.run();
1759            }
1760
1761            sessions.add(session);
1762            notifyDataSetChanged();
1763            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1764        }
1765
1766        public void startMucConfig(XmppConnectionService xmppConnectionService) {
1767            MucConfigSession session = new MucConfigSession(xmppConnectionService);
1768            final var packet = new Iq(Iq.Type.GET);
1769            packet.setTo(Conversation.this.getJid().asBareJid());
1770            packet.addChild("query", "http://jabber.org/protocol/muc#owner");
1771
1772            final TimerTask task = new TimerTask() {
1773                @Override
1774                public void run() {
1775                    if (getAccount().getStatus() != Account.State.ONLINE) {
1776                        final TimerTask self = this;
1777                        new Timer().schedule(new TimerTask() {
1778                            @Override
1779                            public void run() {
1780                                self.run();
1781                            }
1782                        }, 1000);
1783                    } else {
1784                        xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
1785                            session.updateWithResponse(iq);
1786                        }, 120L);
1787                    }
1788                }
1789            };
1790            task.run();
1791
1792            sessions.add(session);
1793            notifyDataSetChanged();
1794            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1795        }
1796
1797        public void removeSession(ConversationPage session) {
1798            sessions.remove(session);
1799            notifyDataSetChanged();
1800            if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1801        }
1802
1803        public boolean switchToSession(final String node) {
1804            if (sessions == null) return false;
1805
1806            int i = 0;
1807            for (ConversationPage session : sessions) {
1808                if (session.getNode().equals(node)) {
1809                    if (mPager != null) mPager.setCurrentItem(i + 2);
1810                    return true;
1811                }
1812                i++;
1813            }
1814
1815            return false;
1816        }
1817
1818        @NonNull
1819        @Override
1820        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1821            if (position == 0) {
1822                if (page1 != null && page1.getParent() != null) {
1823                    ((ViewGroup) page1.getParent()).removeView(page1);
1824                }
1825                container.addView(page1);
1826                return page1;
1827            }
1828            if (position == 1) {
1829                if (page2 != null && page2.getParent() != null) {
1830                    ((ViewGroup) page2.getParent()).removeView(page2);
1831                }
1832                container.addView(page2);
1833                return page2;
1834            }
1835
1836            if (position-2 > sessions.size()) return null;
1837            ConversationPage session = sessions.get(position-2);
1838            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1839            if (v != null && v.getParent() != null) {
1840                ((ViewGroup) v.getParent()).removeView(v);
1841            }
1842            container.addView(v);
1843            return session;
1844        }
1845
1846        @Override
1847        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1848            if (position < 2) {
1849                container.removeView((View) o);
1850                return;
1851            }
1852
1853            container.removeView(((ConversationPage) o).getView());
1854        }
1855
1856        @Override
1857        public int getItemPosition(Object o) {
1858            if (mPager != null) {
1859                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1860                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1861            }
1862
1863            int pos = sessions == null ? -1 : sessions.indexOf(o);
1864            if (pos < 0) return PagerAdapter.POSITION_NONE;
1865            return pos + 2;
1866        }
1867
1868        @Override
1869        public int getCount() {
1870            if (sessions == null) return 1;
1871
1872            int count = 2 + sessions.size();
1873            if (mTabs == null) return count;
1874
1875            if (count > 2) {
1876                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1877            } else {
1878                mTabs.setTabMode(TabLayout.MODE_FIXED);
1879            }
1880            return count;
1881        }
1882
1883        @Override
1884        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1885            if (view == o) return true;
1886
1887            if (o instanceof ConversationPage) {
1888                return ((ConversationPage) o).getView() == view;
1889            }
1890
1891            return false;
1892        }
1893
1894        @Nullable
1895        @Override
1896        public CharSequence getPageTitle(int position) {
1897            switch (position) {
1898                case 0:
1899                    return "Conversation";
1900                case 1:
1901                    return "Commands";
1902                default:
1903                    ConversationPage session = sessions.get(position-2);
1904                    if (session == null) return super.getPageTitle(position);
1905                    return session.getTitle();
1906            }
1907        }
1908
1909        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1910            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1911                protected T binding;
1912
1913                public ViewHolder(T binding) {
1914                    super(binding.getRoot());
1915                    this.binding = binding;
1916                }
1917
1918                abstract public void bind(Item el);
1919
1920                protected void setTextOrHide(TextView v, Optional<String> s) {
1921                    if (s == null || !s.isPresent()) {
1922                        v.setVisibility(View.GONE);
1923                    } else {
1924                        v.setVisibility(View.VISIBLE);
1925                        v.setText(s.get());
1926                    }
1927                }
1928
1929                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1930                    int flags = 0;
1931                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1932                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1933
1934                    String type = field.getAttribute("type");
1935                    if (type != null) {
1936                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1937                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1938                        }
1939
1940                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1941
1942                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1943                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1944                        }
1945
1946                        if (type.equals("text-private")) {
1947                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1948                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1949                        }
1950                    }
1951
1952                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1953                    if (validate == null) return;
1954                    String datatype = validate.getAttribute("datatype");
1955                    if (datatype == null) return;
1956
1957                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1958                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1959                    }
1960
1961                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1962                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1963                    }
1964
1965                    if (datatype.equals("xs:date")) {
1966                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1967                    }
1968
1969                    if (datatype.equals("xs:dateTime")) {
1970                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1971                    }
1972
1973                    if (datatype.equals("xs:time")) {
1974                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1975                    }
1976
1977                    if (datatype.equals("xs:anyURI")) {
1978                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1979                    }
1980
1981                    if (datatype.equals("html:tel")) {
1982                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1983                    }
1984
1985                    if (datatype.equals("html:email")) {
1986                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1987                    }
1988                }
1989
1990                protected String formatValue(String datatype, String value, boolean compact) {
1991                    if ("xs:dateTime".equals(datatype)) {
1992                        ZonedDateTime zonedDateTime = null;
1993                        try {
1994                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1995                        } catch (final DateTimeParseException e) {
1996                            try {
1997                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1998                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
1999                            } catch (final DateTimeParseException e2) { }
2000                        }
2001                        if (zonedDateTime == null) return value;
2002                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
2003                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
2004                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
2005                    }
2006
2007                    if ("html:tel".equals(datatype) && !compact) {
2008                        return PhoneNumberUtils.formatNumber(value, value, null);
2009                    }
2010
2011                    return value;
2012                }
2013            }
2014
2015            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
2016                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
2017
2018                @Override
2019                public void bind(Item iq) {
2020                    binding.errorIcon.setVisibility(View.VISIBLE);
2021
2022                    if (iq == null || iq.el == null) return;
2023                    Element error = iq.el.findChild("error");
2024                    if (error == null) {
2025                        binding.message.setText("Unexpected response: " + iq);
2026                        return;
2027                    }
2028                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
2029                    if (text == null || text.equals("")) {
2030                        text = error.getChildren().get(0).getName();
2031                    }
2032                    binding.message.setText(text);
2033                }
2034            }
2035
2036            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
2037                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
2038
2039                @Override
2040                public void bind(Item note) {
2041                    binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
2042
2043                    String type = note.el.getAttribute("type");
2044                    if (type != null && type.equals("error")) {
2045                        binding.errorIcon.setVisibility(View.VISIBLE);
2046                    }
2047                }
2048            }
2049
2050            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
2051                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
2052
2053                @Override
2054                public void bind(Item item) {
2055                    Field field = (Field) item;
2056                    setTextOrHide(binding.label, field.getLabel());
2057                    setTextOrHide(binding.desc, field.getDesc());
2058
2059                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
2060                    if (media == null) {
2061                        binding.mediaImage.setVisibility(View.GONE);
2062                    } else {
2063                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
2064                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
2065                        for (Element uriEl : media.getChildren()) {
2066                            if (!"uri".equals(uriEl.getName())) continue;
2067                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
2068                            String mimeType = uriEl.getAttribute("type");
2069                            String uriS = uriEl.getContent();
2070                            if (mimeType == null || uriS == null) continue;
2071                            Uri uri = Uri.parse(uriS);
2072                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
2073                                final Drawable d = getDrawableForUrl(uri.toString());
2074                                if (d != null) {
2075                                    binding.mediaImage.setImageDrawable(d);
2076                                    binding.mediaImage.setVisibility(View.VISIBLE);
2077                                }
2078                            }
2079                        }
2080                    }
2081
2082                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2083                    String datatype = validate == null ? null : validate.getAttribute("datatype");
2084
2085                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
2086                    for (Element el : field.el.getChildren()) {
2087                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
2088                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
2089                        }
2090                    }
2091                    binding.values.setAdapter(values);
2092                    Util.justifyListViewHeightBasedOnChildren(binding.values);
2093
2094                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
2095                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2096                            new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
2097                        });
2098                    } else if ("xs:anyURI".equals(datatype)) {
2099                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2100                            new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
2101                        });
2102                    } else if ("html:tel".equals(datatype)) {
2103                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
2104                            try {
2105                                new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
2106                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2107                        });
2108                    }
2109
2110                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
2111                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
2112                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
2113                        }
2114                        return true;
2115                    });
2116                }
2117            }
2118
2119            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
2120                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
2121
2122                @Override
2123                public void bind(Item item) {
2124                    Cell cell = (Cell) item;
2125
2126                    if (cell.el == null) {
2127                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
2128                        setTextOrHide(binding.text, cell.reported.getLabel());
2129                    } else {
2130                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2131                        String datatype = validate == null ? null : validate.getAttribute("datatype");
2132                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
2133                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
2134                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
2135                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2136                        } else if ("xs:anyURI".equals(datatype)) {
2137                            text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2138                        } else if ("html:tel".equals(datatype)) {
2139                            try {
2140                                text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2141                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
2142                        }
2143
2144                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
2145                        binding.text.setText(text);
2146
2147                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
2148                        method.setOnLinkLongClickListener((tv, url) -> {
2149                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2150                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
2151                            return true;
2152                        });
2153                        binding.text.setMovementMethod(method);
2154                    }
2155                }
2156            }
2157
2158            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
2159                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
2160
2161                @Override
2162                public void bind(Item item) {
2163                    binding.fields.removeAllViews();
2164
2165                    for (Field field : reported) {
2166                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2167                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2168                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2169                        param.width = 0;
2170                        row.getRoot().setLayoutParams(param);
2171                        binding.fields.addView(row.getRoot());
2172                        for (Element el : item.el.getChildren()) {
2173                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2174                                for (String label : field.getLabel().asSet()) {
2175                                    el.setAttribute("label", label);
2176                                }
2177                                for (String desc : field.getDesc().asSet()) {
2178                                    el.setAttribute("desc", desc);
2179                                }
2180                                for (String type : field.getType().asSet()) {
2181                                    el.setAttribute("type", type);
2182                                }
2183                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2184                                if (validate != null) el.addChild(validate);
2185                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2186                            }
2187                        }
2188                    }
2189                }
2190            }
2191
2192            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2193                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2194                    super(binding);
2195                    binding.row.setOnClickListener((v) -> {
2196                        binding.checkbox.toggle();
2197                    });
2198                    binding.checkbox.setOnCheckedChangeListener(this);
2199                }
2200                protected Element mValue = null;
2201
2202                @Override
2203                public void bind(Item item) {
2204                    Field field = (Field) item;
2205                    binding.label.setText(field.getLabel().or(""));
2206                    setTextOrHide(binding.desc, field.getDesc());
2207                    mValue = field.getValue();
2208                    final var isChecked = mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1"));
2209                    mValue.setContent(isChecked ? "true" : "false");
2210                    binding.checkbox.setChecked(isChecked);
2211                }
2212
2213                @Override
2214                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2215                    if (mValue == null) return;
2216
2217                    mValue.setContent(isChecked ? "true" : "false");
2218                }
2219            }
2220
2221            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2222                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2223                    super(binding);
2224                    binding.search.addTextChangedListener(this);
2225                }
2226                protected Field field = null;
2227                Set<String> filteredValues;
2228                List<Option> options = new ArrayList<>();
2229                protected ArrayAdapter<Option> adapter;
2230                protected boolean open;
2231                protected boolean multi;
2232                protected int textColor = -1;
2233
2234                @Override
2235                public void bind(Item item) {
2236                    ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2237                    final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2238                    if (fillableFieldCount > 1) {
2239                        layout.height = (int) (density * 200);
2240                    } else {
2241                        layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2242                    }
2243                    binding.list.setLayoutParams(layout);
2244
2245                    field = (Field) item;
2246                    setTextOrHide(binding.label, field.getLabel());
2247                    setTextOrHide(binding.desc, field.getDesc());
2248
2249                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2250                    if (field.error != null) {
2251                        binding.desc.setVisibility(View.VISIBLE);
2252                        binding.desc.setText(field.error);
2253                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2254                    } else {
2255                        binding.desc.setTextColor(textColor);
2256                    }
2257
2258                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2259                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2260                    setupInputType(field.el, binding.search, null);
2261
2262                    multi = field.getType().equals(Optional.of("list-multi"));
2263                    if (multi) {
2264                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2265                    } else {
2266                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2267                    }
2268
2269                    options = field.getOptions();
2270                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
2271                        Set<String> values = new HashSet<>();
2272                        if (multi) {
2273                            values.addAll(field.getValues());
2274                            for (final String value : field.getValues()) {
2275                                if (filteredValues.contains(value)) {
2276                                    values.remove(value);
2277                                }
2278                            }
2279                        }
2280
2281                        SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2282                        for (int i = 0; i < positions.size(); i++) {
2283                            if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2284                        }
2285                        field.setValues(values);
2286
2287                        if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2288                    });
2289                    search("");
2290                }
2291
2292                @Override
2293                public void afterTextChanged(Editable s) {
2294                    if (!multi && open) field.setValues(List.of(s.toString()));
2295                    search(s.toString());
2296                }
2297
2298                @Override
2299                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2300
2301                @Override
2302                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2303
2304                protected void search(String s) {
2305                    List<Option> filteredOptions;
2306                    final String q = s.replaceAll("\\W", "").toLowerCase();
2307                    if (q == null || q.equals("")) {
2308                        filteredOptions = options;
2309                    } else {
2310                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2311                    }
2312                    filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2313                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2314                    binding.list.setAdapter(adapter);
2315
2316                    for (final String value : field.getValues()) {
2317                        int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2318                        if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2319                    }
2320                }
2321            }
2322
2323            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2324                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2325                    super(binding);
2326                    binding.open.addTextChangedListener(this);
2327                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2328                        @Override
2329                        public View getView(int position, View convertView, ViewGroup parent) {
2330                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2331                            v.setId(position);
2332                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2333                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2334                            return v;
2335                        }
2336                    };
2337                }
2338                protected Element mValue = null;
2339                protected ArrayAdapter<Option> options;
2340                protected int textColor = -1;
2341
2342                @Override
2343                public void bind(Item item) {
2344                    Field field = (Field) item;
2345                    setTextOrHide(binding.label, field.getLabel());
2346                    setTextOrHide(binding.desc, field.getDesc());
2347
2348                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2349                    if (field.error != null) {
2350                        binding.desc.setVisibility(View.VISIBLE);
2351                        binding.desc.setText(field.error);
2352                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2353                    } else {
2354                        binding.desc.setTextColor(textColor);
2355                    }
2356
2357                    mValue = field.getValue();
2358
2359                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2360                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2361                    binding.open.setText(mValue.getContent());
2362                    setupInputType(field.el, binding.open, null);
2363
2364                    options.clear();
2365                    List<Option> theOptions = field.getOptions();
2366                    options.addAll(theOptions);
2367
2368                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2369                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2370                    float maxColumnWidth = theOptions.stream().map((x) ->
2371                        StaticLayout.getDesiredWidth(x.toString(), paint)
2372                    ).max(Float::compare).orElse(new Float(0.0));
2373                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2374                        binding.radios.setNumColumns(theOptions.size());
2375                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2376                        binding.radios.setNumColumns(theOptions.size() / 2);
2377                    } else {
2378                        binding.radios.setNumColumns(1);
2379                    }
2380                    binding.radios.setAdapter(options);
2381                }
2382
2383                @Override
2384                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2385                    if (mValue == null) return;
2386
2387                    if (isChecked) {
2388                        mValue.setContent(options.getItem(radio.getId()).getValue());
2389                        binding.open.setText(mValue.getContent());
2390                    }
2391                    options.notifyDataSetChanged();
2392                }
2393
2394                @Override
2395                public void afterTextChanged(Editable s) {
2396                    if (mValue == null) return;
2397
2398                    mValue.setContent(s.toString());
2399                    options.notifyDataSetChanged();
2400                }
2401
2402                @Override
2403                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2404
2405                @Override
2406                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2407            }
2408
2409            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2410                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2411                    super(binding);
2412                    binding.spinner.setOnItemSelectedListener(this);
2413                }
2414                protected Element mValue = null;
2415
2416                @Override
2417                public void bind(Item item) {
2418                    Field field = (Field) item;
2419                    setTextOrHide(binding.label, field.getLabel());
2420                    binding.spinner.setPrompt(field.getLabel().or(""));
2421                    setTextOrHide(binding.desc, field.getDesc());
2422
2423                    mValue = field.getValue();
2424
2425                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2426                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2427                    options.addAll(field.getOptions());
2428
2429                    binding.spinner.setAdapter(options);
2430                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2431                }
2432
2433                @Override
2434                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2435                    Option o = (Option) parent.getItemAtPosition(pos);
2436                    if (mValue == null) return;
2437
2438                    mValue.setContent(o == null ? "" : o.getValue());
2439                }
2440
2441                @Override
2442                public void onNothingSelected(AdapterView<?> parent) {
2443                    mValue.setContent("");
2444                }
2445            }
2446
2447            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2448                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2449                    super(binding);
2450                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2451                        protected int height = 0;
2452
2453                        @Override
2454                        public View getView(int position, View convertView, ViewGroup parent) {
2455                            Button v = (Button) super.getView(position, convertView, parent);
2456                            v.setOnClickListener((view) -> {
2457                                mValue.setContent(getItem(position).getValue());
2458                                execute();
2459                                loading = true;
2460                            });
2461
2462                            final SVG icon = getItem(position).getIcon();
2463                            if (icon != null) {
2464                                 final Element iconEl = getItem(position).getIconEl();
2465                                 if (height < 1) {
2466                                     v.measure(0, 0);
2467                                     height = v.getMeasuredHeight();
2468                                 }
2469                                 if (height < 1) return v;
2470                                 if (mediaSelector) {
2471                                     final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2472                                     if (d != null) {
2473                                         final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2474                                         d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2475                                     }
2476                                     v.setCompoundDrawables(null, d, null, null);
2477                                 } else {
2478                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2479                                 }
2480                            }
2481
2482                            return v;
2483                        }
2484                    };
2485                }
2486                protected Element mValue = null;
2487                protected ArrayAdapter<Option> options;
2488                protected Option defaultOption = null;
2489                protected boolean mediaSelector = false;
2490                protected int textColor = -1;
2491
2492                @Override
2493                public void bind(Item item) {
2494                    Field field = (Field) item;
2495                    setTextOrHide(binding.label, field.getLabel());
2496                    setTextOrHide(binding.desc, field.getDesc());
2497
2498                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2499                    if (field.error != null) {
2500                        binding.desc.setVisibility(View.VISIBLE);
2501                        binding.desc.setText(field.error);
2502                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2503                    } else {
2504                        binding.desc.setTextColor(textColor);
2505                    }
2506
2507                    mValue = field.getValue();
2508                    mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2509
2510                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2511                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2512                    binding.openButton.setOnClickListener((view) -> {
2513                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2514                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2515                        builder.setPositiveButton(R.string.action_execute, null);
2516                        if (field.getDesc().isPresent()) {
2517                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2518                        }
2519                        dialogBinding.inputEditText.requestFocus();
2520                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2521                        builder.setView(dialogBinding.getRoot());
2522                        builder.setNegativeButton(R.string.cancel, null);
2523                        final AlertDialog dialog = builder.create();
2524                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2525                        dialog.show();
2526                        View.OnClickListener clickListener = v -> {
2527                            String value = dialogBinding.inputEditText.getText().toString();
2528                            mValue.setContent(value);
2529                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2530                            dialog.dismiss();
2531                            execute();
2532                            loading = true;
2533                        };
2534                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2535                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2536                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2537                            dialog.dismiss();
2538                        }));
2539                        dialog.setCanceledOnTouchOutside(false);
2540                        dialog.setOnDismissListener(dialog1 -> {
2541                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2542                        });
2543                    });
2544
2545                    options.clear();
2546                    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();
2547
2548                    defaultOption = null;
2549                    for (Option option : theOptions) {
2550                        if (option.getValue().equals(mValue.getContent())) {
2551                            defaultOption = option;
2552                            break;
2553                        }
2554                    }
2555                    if (defaultOption == null && !mValue.getContent().equals("")) {
2556                        // Synthesize default option for custom value
2557                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2558                    }
2559                    if (defaultOption == null) {
2560                        binding.defaultButton.setVisibility(View.GONE);
2561                    } else {
2562                        theOptions.remove(defaultOption);
2563                        binding.defaultButton.setVisibility(View.VISIBLE);
2564
2565                        final SVG defaultIcon = defaultOption.getIcon();
2566                        if (defaultIcon != null) {
2567                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2568                             int height = (int)(display.heightPixels*display.density/4);
2569                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2570                        }
2571
2572                        binding.defaultButton.setText(defaultOption.toString());
2573                        binding.defaultButton.setOnClickListener((view) -> {
2574                            mValue.setContent(defaultOption.getValue());
2575                            execute();
2576                            loading = true;
2577                        });
2578                    }
2579
2580                    options.addAll(theOptions);
2581                    binding.buttons.setAdapter(options);
2582                }
2583            }
2584
2585            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2586                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2587                    super(binding);
2588                    binding.textinput.addTextChangedListener(this);
2589                }
2590                protected Field field = null;
2591
2592                @Override
2593                public void bind(Item item) {
2594                    field = (Field) item;
2595                    binding.textinputLayout.setHint(field.getLabel().or(""));
2596
2597                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2598                    for (String desc : field.getDesc().asSet()) {
2599                        binding.textinputLayout.setHelperText(desc);
2600                    }
2601
2602                    binding.textinputLayout.setErrorEnabled(field.error != null);
2603                    if (field.error != null) binding.textinputLayout.setError(field.error);
2604
2605                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2606                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2607                    if (suffixLabel == null) {
2608                        binding.textinputLayout.setSuffixText("");
2609                    } else {
2610                        binding.textinputLayout.setSuffixText(suffixLabel);
2611                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2612                    }
2613
2614                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2615                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2616
2617                    binding.textinput.setText(String.join("\n", field.getValues()));
2618                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2619                }
2620
2621                @Override
2622                public void afterTextChanged(Editable s) {
2623                    if (field == null) return;
2624
2625                    field.setValues(List.of(s.toString().split("\n")));
2626                }
2627
2628                @Override
2629                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2630
2631                @Override
2632                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2633            }
2634
2635            class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2636                public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2637                protected Field field = null;
2638
2639                @Override
2640                public void bind(Item item) {
2641                    field = (Field) item;
2642                    setTextOrHide(binding.label, field.getLabel());
2643                    setTextOrHide(binding.desc, field.getDesc());
2644                    final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2645                    final String datatype = validate == null ? null : validate.getAttribute("datatype");
2646                    final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2647                    // NOTE: range also implies open, so we don't have to be bound by the options strictly
2648                    // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2649                    Float min = null;
2650                    try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2651                    Float max = null;
2652                    try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max"));  } catch (NumberFormatException e) { }
2653
2654                    List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2655                    Collections.sort(options);
2656                    if (options.size() > 0) {
2657                        // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2658                        if (min == null) min = options.get(0);
2659                        if (max == null) max = options.get(options.size()-1);
2660                    }
2661
2662                    if (field.getValues().size() > 0) {
2663                        final var val = Float.valueOf(field.getValue().getContent());
2664                        if ((min == null || val >= min) && (max == null || val <= max)) {
2665                            binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2666                        } else {
2667                            binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2668                        }
2669                    } else {
2670                        binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2671                    }
2672                    binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2673                    binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2674                    if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2675                        binding.slider.setStepSize(1);
2676                    } else {
2677                        binding.slider.setStepSize(0);
2678                    }
2679
2680                    if (options.size() > 0) {
2681                        float step = -1;
2682                        Float prev = null;
2683                        for (final Float option : options) {
2684                            if (prev != null) {
2685                                float nextStep = option - prev;
2686                                if (step > 0 && step != nextStep) {
2687                                    step = -1;
2688                                    break;
2689                                }
2690                                step = nextStep;
2691                            }
2692                            prev = option;
2693                        }
2694                        if (step > 0) binding.slider.setStepSize(step);
2695                    }
2696
2697                    binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2698                        field.setValues(List.of(new DecimalFormat().format(value)));
2699                    });
2700                }
2701            }
2702
2703            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2704                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2705                protected String boundUrl = "";
2706
2707                @Override
2708                public void bind(Item oob) {
2709                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2710                    binding.webview.getSettings().setJavaScriptEnabled(true);
2711                    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");
2712                    binding.webview.getSettings().setDatabaseEnabled(true);
2713                    binding.webview.getSettings().setDomStorageEnabled(true);
2714                    binding.webview.setWebChromeClient(new WebChromeClient() {
2715                        @Override
2716                        public void onProgressChanged(WebView view, int newProgress) {
2717                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2718                            binding.progressbar.setProgress(newProgress);
2719                        }
2720                    });
2721                    binding.webview.setWebViewClient(new WebViewClient() {
2722                        @Override
2723                        public void onPageFinished(WebView view, String url) {
2724                            super.onPageFinished(view, url);
2725                            mTitle = view.getTitle();
2726                            ConversationPagerAdapter.this.notifyDataSetChanged();
2727                        }
2728                    });
2729                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2730                    if (!boundUrl.equals(url)) {
2731                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2732                        binding.webview.loadUrl(url);
2733                        boundUrl = url;
2734                    }
2735                }
2736
2737                class JsObject {
2738                    @JavascriptInterface
2739                    public void execute() { execute("execute"); }
2740
2741                    @JavascriptInterface
2742                    public void execute(String action) {
2743                        getView().post(() -> {
2744                            actionToWebview = null;
2745                            if(CommandSession.this.execute(action)) {
2746                                removeSession(CommandSession.this);
2747                            }
2748                        });
2749                    }
2750
2751                    @JavascriptInterface
2752                    public void preventDefault() {
2753                        actionToWebview = binding.webview;
2754                    }
2755                }
2756            }
2757
2758            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2759                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2760
2761                @Override
2762                public void bind(Item item) {
2763                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2764                }
2765            }
2766
2767            class Item {
2768                protected Element el;
2769                protected int viewType;
2770                protected String error = null;
2771
2772                Item(Element el, int viewType) {
2773                    this.el = el;
2774                    this.viewType = viewType;
2775                }
2776
2777                public boolean validate() {
2778                    error = null;
2779                    return true;
2780                }
2781            }
2782
2783            class Field extends Item {
2784                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2785
2786                @Override
2787                public boolean validate() {
2788                    if (!super.validate()) return false;
2789                    if (el.findChild("required", "jabber:x:data") == null) return true;
2790                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2791
2792                    error = "this value is required";
2793                    return false;
2794                }
2795
2796                public String getVar() {
2797                    return el.getAttribute("var");
2798                }
2799
2800                public Optional<String> getType() {
2801                    return Optional.fromNullable(el.getAttribute("type"));
2802                }
2803
2804                public Optional<String> getLabel() {
2805                    String label = el.getAttribute("label");
2806                    if (label == null) label = getVar();
2807                    return Optional.fromNullable(label);
2808                }
2809
2810                public Optional<String> getDesc() {
2811                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2812                }
2813
2814                public Element getValue() {
2815                    Element value = el.findChild("value", "jabber:x:data");
2816                    if (value == null) {
2817                        value = el.addChild("value", "jabber:x:data");
2818                    }
2819                    return value;
2820                }
2821
2822                public void setValues(Collection<String> values) {
2823                    for(Element child : el.getChildren()) {
2824                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2825                            el.removeChild(child);
2826                        }
2827                    }
2828
2829                    for (String value : values) {
2830                        el.addChild("value", "jabber:x:data").setContent(value);
2831                    }
2832                }
2833
2834                public List<String> getValues() {
2835                    List<String> values = new ArrayList<>();
2836                    for(Element child : el.getChildren()) {
2837                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2838                            values.add(child.getContent());
2839                        }
2840                    }
2841                    return values;
2842                }
2843
2844                public List<Option> getOptions() {
2845                    return Option.forField(el);
2846                }
2847            }
2848
2849            class Cell extends Item {
2850                protected Field reported;
2851
2852                Cell(Field reported, Element item) {
2853                    super(item, TYPE_RESULT_CELL);
2854                    this.reported = reported;
2855                }
2856            }
2857
2858            protected Field mkField(Element el) {
2859                int viewType = -1;
2860
2861                String formType = responseElement.getAttribute("type");
2862                if (formType != null) {
2863                    String fieldType = el.getAttribute("type");
2864                    if (fieldType == null) fieldType = "text-single";
2865
2866                    if (formType.equals("result") || fieldType.equals("fixed")) {
2867                        viewType = TYPE_RESULT_FIELD;
2868                    } else if (formType.equals("form")) {
2869                        final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2870                        final String datatype = validate == null ? null : validate.getAttribute("datatype");
2871                        final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2872                        if (fieldType.equals("boolean")) {
2873                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2874                                viewType = TYPE_BUTTON_GRID_FIELD;
2875                            } else {
2876                                viewType = TYPE_CHECKBOX_FIELD;
2877                            }
2878                        } else if (
2879                            range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2880                                "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2881                                "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2882                            )
2883                        ) {
2884                            // has a range and is numeric, use a slider
2885                            viewType = TYPE_SLIDER_FIELD;
2886                        } else if (fieldType.equals("list-single")) {
2887                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2888                                viewType = TYPE_BUTTON_GRID_FIELD;
2889                            } else if (Option.forField(el).size() > 9) {
2890                                viewType = TYPE_SEARCH_LIST_FIELD;
2891                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2892                                viewType = TYPE_RADIO_EDIT_FIELD;
2893                            } else {
2894                                viewType = TYPE_SPINNER_FIELD;
2895                            }
2896                        } else if (fieldType.equals("list-multi")) {
2897                            viewType = TYPE_SEARCH_LIST_FIELD;
2898                        } else {
2899                            viewType = TYPE_TEXT_FIELD;
2900                        }
2901                    }
2902
2903                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2904                }
2905
2906                return null;
2907            }
2908
2909            protected Item mkItem(Element el, int pos) {
2910                int viewType = TYPE_ERROR;
2911
2912                if (response != null && response.getType() == Iq.Type.RESULT) {
2913                    if (el.getName().equals("note")) {
2914                        viewType = TYPE_NOTE;
2915                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2916                        viewType = TYPE_WEB;
2917                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2918                        viewType = TYPE_NOTE;
2919                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2920                        Field field = mkField(el);
2921                        if (field != null) {
2922                            items.put(pos, field);
2923                            return field;
2924                        }
2925                    }
2926                }
2927
2928                Item item = new Item(el, viewType);
2929                items.put(pos, item);
2930                return item;
2931            }
2932
2933            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2934                protected Context ctx;
2935
2936                public ActionsAdapter(Context ctx) {
2937                    super(ctx, R.layout.simple_list_item);
2938                    this.ctx = ctx;
2939                }
2940
2941                @Override
2942                public View getView(int position, View convertView, ViewGroup parent) {
2943                    View v = super.getView(position, convertView, parent);
2944                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2945                    tv.setGravity(Gravity.CENTER);
2946                    tv.setText(getItem(position).second);
2947                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2948                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2949                    final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
2950                    tv.setTextColor(colors.getOnAccent());
2951                    tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
2952                    return v;
2953                }
2954
2955                public int getPosition(String s) {
2956                    for(int i = 0; i < getCount(); i++) {
2957                        if (getItem(i).first.equals(s)) return i;
2958                    }
2959                    return -1;
2960                }
2961
2962                public int countProceed() {
2963                    int count = 0;
2964                    for(int i = 0; i < getCount(); i++) {
2965                        if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2966                    }
2967                    return count;
2968                }
2969
2970                public int countExceptCancel() {
2971                    int count = 0;
2972                    for(int i = 0; i < getCount(); i++) {
2973                        if (!getItem(i).first.equals("cancel")) count++;
2974                    }
2975                    return count;
2976                }
2977
2978                public void clearProceed() {
2979                    Pair<String,String> cancelItem = null;
2980                    Pair<String,String> prevItem = null;
2981                    for(int i = 0; i < getCount(); i++) {
2982                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2983                        if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2984                    }
2985                    clear();
2986                    if (cancelItem != null) add(cancelItem);
2987                    if (prevItem != null) add(prevItem);
2988                }
2989            }
2990
2991            final int TYPE_ERROR = 1;
2992            final int TYPE_NOTE = 2;
2993            final int TYPE_WEB = 3;
2994            final int TYPE_RESULT_FIELD = 4;
2995            final int TYPE_TEXT_FIELD = 5;
2996            final int TYPE_CHECKBOX_FIELD = 6;
2997            final int TYPE_SPINNER_FIELD = 7;
2998            final int TYPE_RADIO_EDIT_FIELD = 8;
2999            final int TYPE_RESULT_CELL = 9;
3000            final int TYPE_PROGRESSBAR = 10;
3001            final int TYPE_SEARCH_LIST_FIELD = 11;
3002            final int TYPE_ITEM_CARD = 12;
3003            final int TYPE_BUTTON_GRID_FIELD = 13;
3004            final int TYPE_SLIDER_FIELD = 14;
3005
3006            protected boolean executing = false;
3007            protected boolean loading = false;
3008            protected boolean loadingHasBeenLong = false;
3009            protected Timer loadingTimer = new Timer();
3010            protected String mTitle;
3011            protected String mNode;
3012            protected CommandPageBinding mBinding = null;
3013            protected Iq response = null;
3014            protected Element responseElement = null;
3015            protected boolean expectingRemoval = false;
3016            protected List<Field> reported = null;
3017            protected SparseArray<Item> items = new SparseArray<>();
3018            protected XmppConnectionService xmppConnectionService;
3019            protected ActionsAdapter actionsAdapter = null;
3020            protected GridLayoutManager layoutManager;
3021            protected WebView actionToWebview = null;
3022            protected int fillableFieldCount = 0;
3023            protected Iq pendingResponsePacket = null;
3024            protected boolean waitingForRefresh = false;
3025
3026            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
3027                loading();
3028                mTitle = title;
3029                mNode = node;
3030                this.xmppConnectionService = xmppConnectionService;
3031                if (mPager != null) setupLayoutManager(mPager.getContext());
3032            }
3033
3034            public String getTitle() {
3035                return mTitle;
3036            }
3037
3038            public String getNode() {
3039                return mNode;
3040            }
3041
3042            public void updateWithResponse(final Iq iq) {
3043                if (getView() != null && getView().isAttachedToWindow()) {
3044                    getView().post(() -> updateWithResponseUiThread(iq));
3045                } else {
3046                    pendingResponsePacket = iq;
3047                }
3048            }
3049
3050            protected void updateWithResponseUiThread(final Iq iq) {
3051                Timer oldTimer = this.loadingTimer;
3052                this.loadingTimer = new Timer();
3053                oldTimer.cancel();
3054                this.executing = false;
3055                this.loading = false;
3056                this.loadingHasBeenLong = false;
3057                this.responseElement = null;
3058                this.fillableFieldCount = 0;
3059                this.reported = null;
3060                this.response = iq;
3061                this.items.clear();
3062                this.actionsAdapter.clear();
3063                layoutManager.setSpanCount(1);
3064
3065                boolean actionsCleared = false;
3066                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
3067                if (iq.getType() == Iq.Type.RESULT && command != null) {
3068                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
3069                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
3070                    }
3071
3072                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
3073                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
3074                    }
3075
3076                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
3077                    if (actions != null) {
3078                        for (Element action : actions.getChildren()) {
3079                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
3080                            if ("execute".equals(action.getName())) continue;
3081
3082                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
3083                        }
3084                    }
3085
3086                    for (Element el : command.getChildren()) {
3087                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
3088                            Data form = Data.parse(el);
3089                            String title = form.getTitle();
3090                            if (title != null) {
3091                                mTitle = title;
3092                                ConversationPagerAdapter.this.notifyDataSetChanged();
3093                            }
3094
3095                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
3096                                this.responseElement = el;
3097                                setupReported(el.findChild("reported", "jabber:x:data"));
3098                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3099                            }
3100
3101                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3102                            if (actionList != null) {
3103                                actionsAdapter.clear();
3104
3105                                for (Option action : actionList.getOptions()) {
3106                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
3107                                }
3108                            }
3109
3110                            eu.siacs.conversations.xmpp.forms.Field fillableField = null;
3111                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
3112                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
3113                                   final var validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
3114                                   final var range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
3115                                    fillableField = range == null ? field : null;
3116                                    fillableFieldCount++;
3117                                }
3118                            }
3119
3120                            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))) {
3121                                actionsCleared = true;
3122                                actionsAdapter.clearProceed();
3123                            }
3124                            break;
3125                        }
3126                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
3127                            String url = el.findChildContent("url", "jabber:x:oob");
3128                            if (url != null) {
3129                                String scheme = Uri.parse(url).getScheme();
3130                                if (scheme.equals("http") || scheme.equals("https")) {
3131                                    this.responseElement = el;
3132                                    break;
3133                                }
3134                                if (scheme.equals("xmpp")) {
3135                                    expectingRemoval = true;
3136                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
3137                                    intent.setAction(Intent.ACTION_VIEW);
3138                                    intent.setData(Uri.parse(url));
3139                                    getView().getContext().startActivity(intent);
3140                                    break;
3141                                }
3142                            }
3143                        }
3144                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
3145                            this.responseElement = el;
3146                            break;
3147                        }
3148                    }
3149
3150                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
3151                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
3152                            if (xmppConnectionService.isOnboarding()) {
3153                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
3154                                    xmppConnectionService.deleteAccount(getAccount());
3155                                } else {
3156                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
3157                                        removeSession(this);
3158                                        return;
3159                                    } else {
3160                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
3161                                        xmppConnectionService.deleteAccount(getAccount());
3162                                    }
3163                                }
3164                            }
3165                            xmppConnectionService.archiveConversation(Conversation.this);
3166                        }
3167
3168                        expectingRemoval = true;
3169                        removeSession(this);
3170                        return;
3171                    }
3172
3173                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3174                        // No actions have been given, but we are not done?
3175                        // This is probably a spec violation, but we should do *something*
3176                        actionsAdapter.add(Pair.create("execute", "execute"));
3177                    }
3178
3179                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3180                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3181                            actionsAdapter.add(Pair.create("close", "close"));
3182                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3183                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3184                        }
3185                    }
3186                }
3187
3188                if (actionsAdapter.isEmpty()) {
3189                    actionsAdapter.add(Pair.create("close", "close"));
3190                }
3191
3192                actionsAdapter.sort((x, y) -> {
3193                    if (x.first.equals("cancel")) return -1;
3194                    if (y.first.equals("cancel")) return 1;
3195                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3196                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3197                    return 0;
3198                });
3199
3200                Data dataForm = null;
3201                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3202                if (mNode.equals("jabber:iq:register") &&
3203                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
3204                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3205
3206
3207                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3208                    execute();
3209                }
3210                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3211                notifyDataSetChanged();
3212            }
3213
3214            protected void setupReported(Element el) {
3215                if (el == null) {
3216                    reported = null;
3217                    return;
3218                }
3219
3220                reported = new ArrayList<>();
3221                for (Element fieldEl : el.getChildren()) {
3222                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3223                    reported.add(mkField(fieldEl));
3224                }
3225            }
3226
3227            @Override
3228            public int getItemCount() {
3229                if (loading) return 1;
3230                if (response == null) return 0;
3231                if (response.getType() == Iq.Type.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3232                    int i = 0;
3233                    for (Element el : responseElement.getChildren()) {
3234                        if (!el.getNamespace().equals("jabber:x:data")) continue;
3235                        if (el.getName().equals("title")) continue;
3236                        if (el.getName().equals("field")) {
3237                            String type = el.getAttribute("type");
3238                            if (type != null && type.equals("hidden")) continue;
3239                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3240                        }
3241
3242                        if (el.getName().equals("reported") || el.getName().equals("item")) {
3243                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3244                                if (el.getName().equals("reported")) continue;
3245                                i += 1;
3246                            } else {
3247                                if (reported != null) i += reported.size();
3248                            }
3249                            continue;
3250                        }
3251
3252                        i++;
3253                    }
3254                    return i;
3255                }
3256                return 1;
3257            }
3258
3259            public Item getItem(int position) {
3260                if (loading) return new Item(null, TYPE_PROGRESSBAR);
3261                if (items.get(position) != null) return items.get(position);
3262                if (response == null) return null;
3263
3264                if (response.getType() == Iq.Type.RESULT && responseElement != null) {
3265                    if (responseElement.getNamespace().equals("jabber:x:data")) {
3266                        int i = 0;
3267                        for (Element el : responseElement.getChildren()) {
3268                            if (!el.getNamespace().equals("jabber:x:data")) continue;
3269                            if (el.getName().equals("title")) continue;
3270                            if (el.getName().equals("field")) {
3271                                String type = el.getAttribute("type");
3272                                if (type != null && type.equals("hidden")) continue;
3273                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3274                            }
3275
3276                            if (el.getName().equals("reported") || el.getName().equals("item")) {
3277                                Cell cell = null;
3278
3279                                if (reported != null) {
3280                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3281                                        if (el.getName().equals("reported")) continue;
3282                                        if (i == position) {
3283                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
3284                                            return items.get(position);
3285                                        }
3286                                    } else {
3287                                        if (reported.size() > position - i) {
3288                                            Field reportedField = reported.get(position - i);
3289                                            Element itemField = null;
3290                                            if (el.getName().equals("item")) {
3291                                                for (Element subel : el.getChildren()) {
3292                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
3293                                                       itemField = subel;
3294                                                       break;
3295                                                    }
3296                                                }
3297                                            }
3298                                            cell = new Cell(reportedField, itemField);
3299                                        } else {
3300                                            i += reported.size();
3301                                            continue;
3302                                        }
3303                                    }
3304                                }
3305
3306                                if (cell != null) {
3307                                    items.put(position, cell);
3308                                    return cell;
3309                                }
3310                            }
3311
3312                            if (i < position) {
3313                                i++;
3314                                continue;
3315                            }
3316
3317                            return mkItem(el, position);
3318                        }
3319                    }
3320                }
3321
3322                return mkItem(responseElement == null ? response : responseElement, position);
3323            }
3324
3325            @Override
3326            public int getItemViewType(int position) {
3327                return getItem(position).viewType;
3328            }
3329
3330            @Override
3331            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3332                switch(viewType) {
3333                    case TYPE_ERROR: {
3334                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3335                        return new ErrorViewHolder(binding);
3336                    }
3337                    case TYPE_NOTE: {
3338                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3339                        return new NoteViewHolder(binding);
3340                    }
3341                    case TYPE_WEB: {
3342                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3343                        return new WebViewHolder(binding);
3344                    }
3345                    case TYPE_RESULT_FIELD: {
3346                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3347                        return new ResultFieldViewHolder(binding);
3348                    }
3349                    case TYPE_RESULT_CELL: {
3350                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3351                        return new ResultCellViewHolder(binding);
3352                    }
3353                    case TYPE_ITEM_CARD: {
3354                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3355                        return new ItemCardViewHolder(binding);
3356                    }
3357                    case TYPE_CHECKBOX_FIELD: {
3358                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3359                        return new CheckboxFieldViewHolder(binding);
3360                    }
3361                    case TYPE_SEARCH_LIST_FIELD: {
3362                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3363                        return new SearchListFieldViewHolder(binding);
3364                    }
3365                    case TYPE_RADIO_EDIT_FIELD: {
3366                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3367                        return new RadioEditFieldViewHolder(binding);
3368                    }
3369                    case TYPE_SPINNER_FIELD: {
3370                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3371                        return new SpinnerFieldViewHolder(binding);
3372                    }
3373                    case TYPE_BUTTON_GRID_FIELD: {
3374                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3375                        return new ButtonGridFieldViewHolder(binding);
3376                    }
3377                    case TYPE_TEXT_FIELD: {
3378                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3379                        return new TextFieldViewHolder(binding);
3380                    }
3381                    case TYPE_SLIDER_FIELD: {
3382                        CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3383                        return new SliderFieldViewHolder(binding);
3384                    }
3385                    case TYPE_PROGRESSBAR: {
3386                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3387                        return new ProgressBarViewHolder(binding);
3388                    }
3389                    default:
3390                        if (expectingRemoval) {
3391                            CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3392                            return new NoteViewHolder(binding);
3393                        }
3394
3395                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3396                }
3397            }
3398
3399            @Override
3400            public void onBindViewHolder(ViewHolder viewHolder, int position) {
3401                viewHolder.bind(getItem(position));
3402            }
3403
3404            public View getView() {
3405                if (mBinding == null) return null;
3406                return mBinding.getRoot();
3407            }
3408
3409            public boolean validate() {
3410                int count = getItemCount();
3411                boolean isValid = true;
3412                for (int i = 0; i < count; i++) {
3413                    boolean oneIsValid = getItem(i).validate();
3414                    isValid = isValid && oneIsValid;
3415                }
3416                notifyDataSetChanged();
3417                return isValid;
3418            }
3419
3420            public boolean execute() {
3421                return execute("execute");
3422            }
3423
3424            public boolean execute(int actionPosition) {
3425                return execute(actionsAdapter.getItem(actionPosition).first);
3426            }
3427
3428            public synchronized boolean execute(String action) {
3429                if (!"cancel".equals(action) && executing) {
3430                    loadingHasBeenLong = true;
3431                    notifyDataSetChanged();
3432                    return false;
3433                }
3434                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3435
3436                if (response == null) return true;
3437                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3438                if (command == null) return true;
3439                String status = command.getAttribute("status");
3440                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3441
3442                if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3443                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3444                    return false;
3445                }
3446
3447                final var packet = new Iq(Iq.Type.SET);
3448                packet.setTo(response.getFrom());
3449                final Element c = packet.addChild("command", Namespace.COMMANDS);
3450                c.setAttribute("node", mNode);
3451                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3452
3453                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3454                if (!action.equals("cancel") &&
3455                    !action.equals("prev") &&
3456                    responseElement != null &&
3457                    responseElement.getName().equals("x") &&
3458                    responseElement.getNamespace().equals("jabber:x:data") &&
3459                    formType != null && formType.equals("form")) {
3460
3461                    Data form = Data.parse(responseElement);
3462                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3463                    if (actionList != null) {
3464                        actionList.setValue(action);
3465                        c.setAttribute("action", "execute");
3466                    }
3467
3468                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3469                        if (form.getValue("gateway-jid") == null) {
3470                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3471                        } else {
3472                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3473                        }
3474                    }
3475
3476                    responseElement.setAttribute("type", "submit");
3477                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3478                    if (rsm != null) {
3479                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3480                        max.setContent("1000");
3481                        rsm.addChild(max);
3482                    }
3483
3484                    c.addChild(responseElement);
3485                }
3486
3487                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3488
3489                executing = true;
3490                xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3491                    updateWithResponse(iq);
3492                }, 120L);
3493
3494                loading();
3495                return false;
3496            }
3497
3498            public void refresh() {
3499                synchronized(this) {
3500                    if (waitingForRefresh) notifyDataSetChanged();
3501                }
3502            }
3503
3504            protected void loading() {
3505                View v = getView();
3506                try {
3507                    loadingTimer.schedule(new TimerTask() {
3508                        @Override
3509                        public void run() {
3510                            View v2 = getView();
3511                            loading = true;
3512
3513                            try {
3514                                loadingTimer.schedule(new TimerTask() {
3515                                    @Override
3516                                    public void run() {
3517                                        loadingHasBeenLong = true;
3518                                        if (v == null && v2 == null) return;
3519                                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3520                                    }
3521                                }, 3000);
3522                            } catch (final IllegalStateException e) { }
3523
3524                            if (v == null && v2 == null) return;
3525                            (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3526                        }
3527                    }, 500);
3528                } catch (final IllegalStateException e) { }
3529            }
3530
3531            protected GridLayoutManager setupLayoutManager(final Context ctx) {
3532                int spanCount = 1;
3533
3534                if (reported != null) {
3535                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3536                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3537                    float tableHeaderWidth = reported.stream().reduce(
3538                        0f,
3539                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3540                        (a, b) -> a + b
3541                    );
3542
3543                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3544                }
3545
3546                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3547                    items.clear();
3548                    notifyDataSetChanged();
3549                }
3550
3551                layoutManager = new GridLayoutManager(ctx, spanCount);
3552                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3553                    @Override
3554                    public int getSpanSize(int position) {
3555                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3556                        return 1;
3557                    }
3558                });
3559                return layoutManager;
3560            }
3561
3562            protected void setBinding(CommandPageBinding b) {
3563                mBinding = b;
3564                // https://stackoverflow.com/a/32350474/8611
3565                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3566                    @Override
3567                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3568                        if(rv.getChildCount() > 0) {
3569                            int[] location = new int[2];
3570                            rv.getLocationOnScreen(location);
3571                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3572                            if (childView instanceof ViewGroup) {
3573                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3574                            }
3575                            int action = e.getAction();
3576                            switch (action) {
3577                                case MotionEvent.ACTION_DOWN:
3578                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3579                                        rv.requestDisallowInterceptTouchEvent(true);
3580                                    }
3581                                case MotionEvent.ACTION_UP:
3582                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3583                                        rv.requestDisallowInterceptTouchEvent(true);
3584                                    }
3585                            }
3586                        }
3587
3588                        return false;
3589                    }
3590
3591                    @Override
3592                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3593
3594                    @Override
3595                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3596                });
3597                mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3598                mBinding.form.setAdapter(this);
3599
3600                if (actionsAdapter == null) {
3601                    actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3602                    actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3603                        @Override
3604                        public void onChanged() {
3605                            if (mBinding == null) return;
3606
3607                            mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3608                        }
3609
3610                        @Override
3611                        public void onInvalidated() {}
3612                    });
3613                }
3614
3615                mBinding.actions.setAdapter(actionsAdapter);
3616                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3617                    if (execute(pos)) {
3618                        removeSession(CommandSession.this);
3619                    }
3620                });
3621
3622                actionsAdapter.notifyDataSetChanged();
3623
3624                if (pendingResponsePacket != null) {
3625                    final var pending = pendingResponsePacket;
3626                    pendingResponsePacket = null;
3627                    updateWithResponseUiThread(pending);
3628                }
3629            }
3630
3631            private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3632               if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image"))  {
3633                   return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3634               } else {
3635                   return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3636               }
3637            }
3638
3639            private Drawable getDrawableForUrl(final String url) {
3640                final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3641                final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3642                final Drawable d = cache.get(url);
3643                if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3644                if (d == null) {
3645                    synchronized (CommandSession.this) {
3646                        waitingForRefresh = true;
3647                    }
3648                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3649                    Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3650                    dummy.setStatus(Message.STATUS_DUMMY);
3651                    dummy.setFileParams(new Message.FileParams(url));
3652                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3653                        if (file == null) {
3654                            dummy.getTransferable().start();
3655                        } else {
3656                            try {
3657                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3658                            } catch (final Exception e) { }
3659                        }
3660                    });
3661                }
3662                return d;
3663            }
3664
3665            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3666                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3667                setBinding(binding);
3668                return binding.getRoot();
3669            }
3670
3671            // https://stackoverflow.com/a/36037991/8611
3672            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3673                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3674                    View child = viewGroup.getChildAt(i);
3675                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3676                        View foundView = findViewAt((ViewGroup) child, x, y);
3677                        if (foundView != null && foundView.isShown()) {
3678                            return foundView;
3679                        }
3680                    } else {
3681                        int[] location = new int[2];
3682                        child.getLocationOnScreen(location);
3683                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3684                        if (rect.contains((int)x, (int)y)) {
3685                            return child;
3686                        }
3687                    }
3688                }
3689
3690                return null;
3691            }
3692        }
3693
3694        class MucConfigSession extends CommandSession {
3695            MucConfigSession(XmppConnectionService xmppConnectionService) {
3696                super("Configure Channel", null, xmppConnectionService);
3697            }
3698
3699            @Override
3700            protected void updateWithResponseUiThread(final Iq iq) {
3701                Timer oldTimer = this.loadingTimer;
3702                this.loadingTimer = new Timer();
3703                oldTimer.cancel();
3704                this.executing = false;
3705                this.loading = false;
3706                this.loadingHasBeenLong = false;
3707                this.responseElement = null;
3708                this.fillableFieldCount = 0;
3709                this.reported = null;
3710                this.response = iq;
3711                this.items.clear();
3712                this.actionsAdapter.clear();
3713                layoutManager.setSpanCount(1);
3714
3715                final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3716                if (iq.getType() == Iq.Type.RESULT && query != null) {
3717                    final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3718                    final String title = form.getTitle();
3719                    if (title != null) {
3720                        mTitle = title;
3721                        ConversationPagerAdapter.this.notifyDataSetChanged();
3722                    }
3723
3724                    this.responseElement = form;
3725                    setupReported(form.findChild("reported", "jabber:x:data"));
3726                    if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager(mBinding.getRoot().getContext()));
3727
3728                    if (actionsAdapter.countExceptCancel() < 1) {
3729                        actionsAdapter.add(Pair.create("save", "Save"));
3730                    }
3731
3732                    if (actionsAdapter.getPosition("cancel") < 0) {
3733                        actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3734                    }
3735                } else if (iq.getType() == Iq.Type.RESULT) {
3736                    expectingRemoval = true;
3737                    removeSession(this);
3738                    return;
3739                } else {
3740                    actionsAdapter.add(Pair.create("close", "close"));
3741                }
3742
3743                notifyDataSetChanged();
3744            }
3745
3746            @Override
3747            public synchronized boolean execute(String action) {
3748                if ("cancel".equals(action)) {
3749                    final var packet = new Iq(Iq.Type.SET);
3750                    packet.setTo(response.getFrom());
3751                    final Element form = packet
3752                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3753                        .addChild("x", "jabber:x:data");
3754                    form.setAttribute("type", "cancel");
3755                    xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3756                    return true;
3757                }
3758
3759                if (!"save".equals(action)) return true;
3760
3761                final var packet = new Iq(Iq.Type.SET);
3762                packet.setTo(response.getFrom());
3763
3764                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3765                if (responseElement != null &&
3766                    responseElement.getName().equals("x") &&
3767                    responseElement.getNamespace().equals("jabber:x:data") &&
3768                    formType != null && formType.equals("form")) {
3769
3770                    responseElement.setAttribute("type", "submit");
3771                    packet
3772                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3773                        .addChild(responseElement);
3774                }
3775
3776                executing = true;
3777                xmppConnectionService.sendIqPacket(getAccount(), packet, (iq) -> {
3778                    updateWithResponse(iq);
3779                }, 120L);
3780
3781                loading();
3782
3783                return false;
3784            }
3785        }
3786    }
3787
3788    public static class Thread {
3789        protected Message subject = null;
3790        protected Message first = null;
3791        protected Message last = null;
3792        protected final String threadId;
3793
3794        protected Thread(final String threadId) {
3795            this.threadId = threadId;
3796        }
3797
3798        public String getThreadId() {
3799            return threadId;
3800        }
3801
3802        public String getSubject() {
3803            if (subject == null) return null;
3804
3805            return subject.getSubject();
3806        }
3807
3808        public String getDisplay() {
3809            final String s = getSubject();
3810            if (s != null) return s;
3811
3812            if (first != null) {
3813                return first.getBody();
3814            }
3815
3816            return "";
3817        }
3818
3819        public long getLastTime() {
3820            if (last == null) return 0;
3821
3822            return last.getTimeSent();
3823        }
3824    }
3825}