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