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