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