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