Conversation.java

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