Conversation.java

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