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 add(Message message) {
1260        synchronized (this.messages) {
1261            this.messages.add(message);
1262        }
1263    }
1264
1265    public void prepend(int offset, Message message) {
1266        synchronized (this.messages) {
1267            this.messages.add(Math.min(offset, this.messages.size()), message);
1268        }
1269    }
1270
1271    public void addAll(int index, List<Message> messages) {
1272        synchronized (this.messages) {
1273            this.messages.addAll(index, messages);
1274        }
1275        account.getPgpDecryptionService().decrypt(messages);
1276    }
1277
1278    public void expireOldMessages(long timestamp) {
1279        synchronized (this.messages) {
1280            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1281                if (iterator.next().getTimeSent() < timestamp) {
1282                    iterator.remove();
1283                }
1284            }
1285            untieMessages();
1286        }
1287    }
1288
1289    public void sort() {
1290        synchronized (this.messages) {
1291            Collections.sort(this.messages, (left, right) -> {
1292                if (left.getTimeSent() < right.getTimeSent()) {
1293                    return -1;
1294                } else if (left.getTimeSent() > right.getTimeSent()) {
1295                    return 1;
1296                } else {
1297                    return 0;
1298                }
1299            });
1300            untieMessages();
1301        }
1302    }
1303
1304    private void untieMessages() {
1305        for (Message message : this.messages) {
1306            message.untie();
1307        }
1308    }
1309
1310    public int unreadCount() {
1311        synchronized (this.messages) {
1312            int count = 0;
1313            for(final Message message : Lists.reverse(this.messages)) {
1314                if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1315                if (message.isRead()) {
1316                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1317                        continue;
1318                    }
1319                    return count;
1320                }
1321                ++count;
1322            }
1323            return count;
1324        }
1325    }
1326
1327    public int receivedMessagesCount() {
1328        int count = 0;
1329        synchronized (this.messages) {
1330            for (Message message : messages) {
1331                if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
1332                if (message.getStatus() == Message.STATUS_RECEIVED) {
1333                    ++count;
1334                }
1335            }
1336        }
1337        return count;
1338    }
1339
1340    public int sentMessagesCount() {
1341        int count = 0;
1342        synchronized (this.messages) {
1343            for (Message message : messages) {
1344                if (message.getStatus() != Message.STATUS_RECEIVED) {
1345                    ++count;
1346                }
1347            }
1348        }
1349        return count;
1350    }
1351
1352    public boolean canInferPresence() {
1353        final Contact contact = getContact();
1354        if (contact != null && contact.canInferPresence()) return true;
1355        return sentMessagesCount() > 0;
1356    }
1357
1358    public boolean isWithStranger() {
1359        final Contact contact = getContact();
1360        return mode == MODE_SINGLE
1361                && !contact.isOwnServer()
1362                && !contact.showInContactList()
1363                && !contact.isSelf()
1364                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1365                && sentMessagesCount() == 0;
1366    }
1367
1368    public int getReceivedMessagesCountSinceUuid(String uuid) {
1369        if (uuid == null) {
1370            return 0;
1371        }
1372        int count = 0;
1373        synchronized (this.messages) {
1374            for (int i = messages.size() - 1; i >= 0; i--) {
1375                final Message message = messages.get(i);
1376                if (uuid.equals(message.getUuid())) {
1377                    return count;
1378                }
1379                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1380                    ++count;
1381                }
1382            }
1383        }
1384        return 0;
1385    }
1386
1387    @Override
1388    public int getAvatarBackgroundColor() {
1389        return UIHelper.getColorForName(getName().toString());
1390    }
1391
1392    @Override
1393    public String getAvatarName() {
1394        return getName().toString();
1395    }
1396
1397    public void setCurrentTab(int tab) {
1398        mCurrentTab = tab;
1399    }
1400
1401    public int getCurrentTab() {
1402        if (mCurrentTab >= 0) return mCurrentTab;
1403
1404        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1405            return 0;
1406        }
1407
1408        return 1;
1409    }
1410
1411    public void refreshSessions() {
1412        pagerAdapter.refreshSessions();
1413    }
1414
1415    public void startWebxdc(WebxdcPage page) {
1416        pagerAdapter.startWebxdc(page);
1417    }
1418
1419    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1420        pagerAdapter.startCommand(command, xmppConnectionService);
1421    }
1422
1423    public void startMucConfig(XmppConnectionService xmppConnectionService) {
1424        pagerAdapter.startMucConfig(xmppConnectionService);
1425    }
1426
1427    public boolean switchToSession(final String node) {
1428        return pagerAdapter.switchToSession(node);
1429    }
1430
1431    public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1432        pagerAdapter.setupViewPager(pager, tabs, onboarding, oldConversation);
1433    }
1434
1435    public void showViewPager() {
1436        pagerAdapter.show();
1437    }
1438
1439    public void hideViewPager() {
1440        pagerAdapter.hide();
1441    }
1442
1443    public void setDisplayState(final String stanzaId) {
1444        this.displayState = stanzaId;
1445    }
1446
1447    public String getDisplayState() {
1448        return this.displayState;
1449    }
1450
1451    public interface OnMessageFound {
1452        void onMessageFound(final Message message);
1453    }
1454
1455    public static class Draft {
1456        private final String message;
1457        private final long timestamp;
1458
1459        private Draft(String message, long timestamp) {
1460            this.message = message;
1461            this.timestamp = timestamp;
1462        }
1463
1464        public long getTimestamp() {
1465            return timestamp;
1466        }
1467
1468        public String getMessage() {
1469            return message;
1470        }
1471    }
1472
1473    public class ConversationPagerAdapter extends PagerAdapter {
1474        protected ViewPager mPager = null;
1475        protected TabLayout mTabs = null;
1476        ArrayList<ConversationPage> sessions = null;
1477        protected View page1 = null;
1478        protected View page2 = null;
1479        protected boolean mOnboarding = false;
1480
1481        public void setupViewPager(ViewPager pager, TabLayout tabs, boolean onboarding, Conversation oldConversation) {
1482            mPager = pager;
1483            mTabs = tabs;
1484            mOnboarding = onboarding;
1485
1486            if (oldConversation != null) {
1487                oldConversation.pagerAdapter.mPager = null;
1488                oldConversation.pagerAdapter.mTabs = null;
1489            }
1490
1491            if (mPager == null) {
1492                page1 = null;
1493                page2 = null;
1494                return;
1495            }
1496            if (sessions != null) show();
1497
1498            if (pager.getChildAt(0) != null) page1 = pager.getChildAt(0);
1499            if (pager.getChildAt(1) != null) page2 = pager.getChildAt(1);
1500            if (page2 != null && page2.findViewById(R.id.commands_view) == null) {
1501                page1 = null;
1502                page2 = null;
1503            }
1504            if (page1 == null) page1 = oldConversation.pagerAdapter.page1;
1505            if (page2 == null) page2 = oldConversation.pagerAdapter.page2;
1506            if (page1 == null || page2 == null) {
1507                throw new IllegalStateException("page1 or page2 were not present as child or in model?");
1508            }
1509            pager.removeView(page1);
1510            pager.removeView(page2);
1511            pager.setAdapter(this);
1512            tabs.setupWithViewPager(mPager);
1513            pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1514
1515            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1516                public void onPageScrollStateChanged(int state) { }
1517                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1518
1519                public void onPageSelected(int position) {
1520                    setCurrentTab(position);
1521                }
1522            });
1523        }
1524
1525        public void show() {
1526            if (sessions == null) {
1527                sessions = new ArrayList<>();
1528                notifyDataSetChanged();
1529            }
1530            if (!mOnboarding && mTabs != null) mTabs.setVisibility(View.VISIBLE);
1531        }
1532
1533        public void hide() {
1534            if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1535            if (mPager != null) mPager.setCurrentItem(0);
1536            if (mTabs != null) mTabs.setVisibility(View.GONE);
1537            sessions = null;
1538            notifyDataSetChanged();
1539        }
1540
1541        public void refreshSessions() {
1542            if (sessions == null) return;
1543
1544            for (ConversationPage session : sessions) {
1545                session.refresh();
1546            }
1547        }
1548
1549        public void startWebxdc(WebxdcPage page) {
1550            show();
1551            sessions.add(page);
1552            notifyDataSetChanged();
1553            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1554        }
1555
1556        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1557            show();
1558            CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
1559
1560            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1561            packet.setTo(command.getAttributeAsJid("jid"));
1562            final Element c = packet.addChild("command", Namespace.COMMANDS);
1563            c.setAttribute("node", command.getAttribute("node"));
1564            c.setAttribute("action", "execute");
1565
1566            final TimerTask task = new TimerTask() {
1567                @Override
1568                public void run() {
1569                    if (getAccount().getStatus() != Account.State.ONLINE) {
1570                        final TimerTask self = this;
1571                        new Timer().schedule(new TimerTask() {
1572                            @Override
1573                            public void run() {
1574                                self.run();
1575                            }
1576                        }, 1000);
1577                    } else {
1578                        xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1579                            session.updateWithResponse(iq);
1580                        }, 120L);
1581                    }
1582                }
1583            };
1584
1585            if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1586                new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1587                    if (signedData != null && signature != null) {
1588                        c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1589                        c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1590                    }
1591
1592                    task.run();
1593                }).checkLicense();
1594            } else {
1595                task.run();
1596            }
1597
1598            sessions.add(session);
1599            notifyDataSetChanged();
1600            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1601        }
1602
1603        public void startMucConfig(XmppConnectionService xmppConnectionService) {
1604            MucConfigSession session = new MucConfigSession(xmppConnectionService);
1605            final IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
1606            packet.setTo(Conversation.this.getJid().asBareJid());
1607            packet.addChild("query", "http://jabber.org/protocol/muc#owner");
1608
1609            final TimerTask task = new TimerTask() {
1610                @Override
1611                public void run() {
1612                    if (getAccount().getStatus() != Account.State.ONLINE) {
1613                        final TimerTask self = this;
1614                        new Timer().schedule(new TimerTask() {
1615                            @Override
1616                            public void run() {
1617                                self.run();
1618                            }
1619                        }, 1000);
1620                    } else {
1621                        xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1622                            session.updateWithResponse(iq);
1623                        }, 120L);
1624                    }
1625                }
1626            };
1627            task.run();
1628
1629            sessions.add(session);
1630            notifyDataSetChanged();
1631            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1632        }
1633
1634        public void removeSession(ConversationPage session) {
1635            sessions.remove(session);
1636            notifyDataSetChanged();
1637            if (session instanceof WebxdcPage) mPager.setCurrentItem(0);
1638        }
1639
1640        public boolean switchToSession(final String node) {
1641            if (sessions == null) return false;
1642
1643            int i = 0;
1644            for (ConversationPage session : sessions) {
1645                if (session.getNode().equals(node)) {
1646                    if (mPager != null) mPager.setCurrentItem(i + 2);
1647                    return true;
1648                }
1649                i++;
1650            }
1651
1652            return false;
1653        }
1654
1655        @NonNull
1656        @Override
1657        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1658            if (position == 0) {
1659                if (page1 != null && page1.getParent() != null) {
1660                    ((ViewGroup) page1.getParent()).removeView(page1);
1661                }
1662                container.addView(page1);
1663                return page1;
1664            }
1665            if (position == 1) {
1666                if (page2 != null && page2.getParent() != null) {
1667                    ((ViewGroup) page2.getParent()).removeView(page2);
1668                }
1669                container.addView(page2);
1670                return page2;
1671            }
1672
1673            ConversationPage session = sessions.get(position-2);
1674            View v = session.inflateUi(container.getContext(), (s) -> removeSession(s));
1675            if (v != null && v.getParent() != null) {
1676                ((ViewGroup) v.getParent()).removeView(v);
1677            }
1678            container.addView(v);
1679            return session;
1680        }
1681
1682        @Override
1683        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1684            if (position < 2) {
1685                container.removeView((View) o);
1686                return;
1687            }
1688
1689            container.removeView(((ConversationPage) o).getView());
1690        }
1691
1692        @Override
1693        public int getItemPosition(Object o) {
1694            if (mPager != null) {
1695                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1696                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1697            }
1698
1699            int pos = sessions == null ? -1 : sessions.indexOf(o);
1700            if (pos < 0) return PagerAdapter.POSITION_NONE;
1701            return pos + 2;
1702        }
1703
1704        @Override
1705        public int getCount() {
1706            if (sessions == null) return 1;
1707
1708            int count = 2 + sessions.size();
1709            if (mTabs == null) return count;
1710
1711            if (count > 2) {
1712                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1713            } else {
1714                mTabs.setTabMode(TabLayout.MODE_FIXED);
1715            }
1716            return count;
1717        }
1718
1719        @Override
1720        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1721            if (view == o) return true;
1722
1723            if (o instanceof ConversationPage) {
1724                return ((ConversationPage) o).getView() == view;
1725            }
1726
1727            return false;
1728        }
1729
1730        @Nullable
1731        @Override
1732        public CharSequence getPageTitle(int position) {
1733            switch (position) {
1734                case 0:
1735                    return "Conversation";
1736                case 1:
1737                    return "Commands";
1738                default:
1739                    ConversationPage session = sessions.get(position-2);
1740                    if (session == null) return super.getPageTitle(position);
1741                    return session.getTitle();
1742            }
1743        }
1744
1745        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
1746            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1747                protected T binding;
1748
1749                public ViewHolder(T binding) {
1750                    super(binding.getRoot());
1751                    this.binding = binding;
1752                }
1753
1754                abstract public void bind(Item el);
1755
1756                protected void setTextOrHide(TextView v, Optional<String> s) {
1757                    if (s == null || !s.isPresent()) {
1758                        v.setVisibility(View.GONE);
1759                    } else {
1760                        v.setVisibility(View.VISIBLE);
1761                        v.setText(s.get());
1762                    }
1763                }
1764
1765                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1766                    int flags = 0;
1767                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1768                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1769
1770                    String type = field.getAttribute("type");
1771                    if (type != null) {
1772                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1773                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1774                        }
1775
1776                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1777
1778                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1779                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1780                        }
1781
1782                        if (type.equals("text-private")) {
1783                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1784                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1785                        }
1786                    }
1787
1788                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1789                    if (validate == null) return;
1790                    String datatype = validate.getAttribute("datatype");
1791                    if (datatype == null) return;
1792
1793                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1794                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1795                    }
1796
1797                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1798                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1799                    }
1800
1801                    if (datatype.equals("xs:date")) {
1802                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1803                    }
1804
1805                    if (datatype.equals("xs:dateTime")) {
1806                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1807                    }
1808
1809                    if (datatype.equals("xs:time")) {
1810                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1811                    }
1812
1813                    if (datatype.equals("xs:anyURI")) {
1814                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1815                    }
1816
1817                    if (datatype.equals("html:tel")) {
1818                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1819                    }
1820
1821                    if (datatype.equals("html:email")) {
1822                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1823                    }
1824                }
1825
1826                protected String formatValue(String datatype, String value, boolean compact) {
1827                    if ("xs:dateTime".equals(datatype)) {
1828                        ZonedDateTime zonedDateTime = null;
1829                        try {
1830                            zonedDateTime = ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME);
1831                        } catch (final DateTimeParseException e) {
1832                            try {
1833                                DateTimeFormatter almostIso = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm[:ss] X");
1834                                zonedDateTime = ZonedDateTime.parse(value, almostIso);
1835                            } catch (final DateTimeParseException e2) { }
1836                        }
1837                        if (zonedDateTime == null) return value;
1838                        ZonedDateTime localZonedDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
1839                        DateTimeFormatter outputFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
1840                        return localZonedDateTime.toLocalDateTime().format(outputFormat);
1841                    }
1842
1843                    if ("html:tel".equals(datatype) && !compact) {
1844                        return PhoneNumberUtils.formatNumber(value, value, null);
1845                    }
1846
1847                    return value;
1848                }
1849            }
1850
1851            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1852                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1853
1854                @Override
1855                public void bind(Item iq) {
1856                    binding.errorIcon.setVisibility(View.VISIBLE);
1857
1858                    if (iq == null || iq.el == null) return;
1859                    Element error = iq.el.findChild("error");
1860                    if (error == null) {
1861                        binding.message.setText("Unexpected response: " + iq);
1862                        return;
1863                    }
1864                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1865                    if (text == null || text.equals("")) {
1866                        text = error.getChildren().get(0).getName();
1867                    }
1868                    binding.message.setText(text);
1869                }
1870            }
1871
1872            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1873                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1874
1875                @Override
1876                public void bind(Item note) {
1877                    binding.message.setText(note != null && note.el != null ? note.el.getContent() : "");
1878
1879                    String type = note.el.getAttribute("type");
1880                    if (type != null && type.equals("error")) {
1881                        binding.errorIcon.setVisibility(View.VISIBLE);
1882                    }
1883                }
1884            }
1885
1886            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1887                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1888
1889                @Override
1890                public void bind(Item item) {
1891                    Field field = (Field) item;
1892                    setTextOrHide(binding.label, field.getLabel());
1893                    setTextOrHide(binding.desc, field.getDesc());
1894
1895                    Element media = field.el.findChild("media", "urn:xmpp:media-element");
1896                    if (media == null) {
1897                        binding.mediaImage.setVisibility(View.GONE);
1898                    } else {
1899                        final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
1900                        final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
1901                        for (Element uriEl : media.getChildren()) {
1902                            if (!"uri".equals(uriEl.getName())) continue;
1903                            if (!"urn:xmpp:media-element".equals(uriEl.getNamespace())) continue;
1904                            String mimeType = uriEl.getAttribute("type");
1905                            String uriS = uriEl.getContent();
1906                            if (mimeType == null || uriS == null) continue;
1907                            Uri uri = Uri.parse(uriS);
1908                            if (mimeType.startsWith("image/") && "https".equals(uri.getScheme())) {
1909                                final Drawable d = getDrawableForUrl(uri.toString());
1910                                if (d != null) {
1911                                    binding.mediaImage.setImageDrawable(d);
1912                                    binding.mediaImage.setVisibility(View.VISIBLE);
1913                                }
1914                            }
1915                        }
1916                    }
1917
1918                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1919                    String datatype = validate == null ? null : validate.getAttribute("datatype");
1920
1921                    ArrayAdapter<Option> values = new ArrayAdapter<>(binding.getRoot().getContext(), R.layout.simple_list_item);
1922                    for (Element el : field.el.getChildren()) {
1923                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1924                            values.add(new Option(el.getContent(), formatValue(datatype, el.getContent(), false)));
1925                        }
1926                    }
1927                    binding.values.setAdapter(values);
1928                    Util.justifyListViewHeightBasedOnChildren(binding.values);
1929
1930                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1931                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1932                            new FixedURLSpan("xmpp:" + Uri.encode(Jid.ofEscaped(values.getItem(pos).getValue()).toEscapedString(), "@/+"), account).onClick(binding.values);
1933                        });
1934                    } else if ("xs:anyURI".equals(datatype)) {
1935                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1936                            new FixedURLSpan(values.getItem(pos).getValue(), account).onClick(binding.values);
1937                        });
1938                    } else if ("html:tel".equals(datatype)) {
1939                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1940                            try {
1941                                new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), values.getItem(pos).getValue()), account).onClick(binding.values);
1942                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1943                        });
1944                    }
1945
1946                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1947                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos).getValue(), R.string.message)) {
1948                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1949                        }
1950                        return true;
1951                    });
1952                }
1953            }
1954
1955            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1956                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1957
1958                @Override
1959                public void bind(Item item) {
1960                    Cell cell = (Cell) item;
1961
1962                    if (cell.el == null) {
1963                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_TitleMedium);
1964                        setTextOrHide(binding.text, cell.reported.getLabel());
1965                    } else {
1966                        Element validate = cell.reported.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1967                        String datatype = validate == null ? null : validate.getAttribute("datatype");
1968                        String value = formatValue(datatype, cell.el.findChildContent("value", "jabber:x:data"), true);
1969                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1970                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1971                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1972                        } else if ("xs:anyURI".equals(datatype)) {
1973                            text.setSpan(new FixedURLSpan(text.toString(), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1974                        } else if ("html:tel".equals(datatype)) {
1975                            try {
1976                                text.setSpan(new FixedURLSpan("tel:" + PhoneNumberUtilWrapper.normalize(binding.getRoot().getContext(), text.toString()), account), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1977                            } catch (final IllegalArgumentException | NumberParseException | NullPointerException e) { }
1978                        }
1979
1980                        binding.text.setTextAppearance(binding.getRoot().getContext(), com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1981                        binding.text.setText(text);
1982
1983                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1984                        method.setOnLinkLongClickListener((tv, url) -> {
1985                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1986                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1987                            return true;
1988                        });
1989                        binding.text.setMovementMethod(method);
1990                    }
1991                }
1992            }
1993
1994            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1995                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1996
1997                @Override
1998                public void bind(Item item) {
1999                    binding.fields.removeAllViews();
2000
2001                    for (Field field : reported) {
2002                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
2003                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
2004                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
2005                        param.width = 0;
2006                        row.getRoot().setLayoutParams(param);
2007                        binding.fields.addView(row.getRoot());
2008                        for (Element el : item.el.getChildren()) {
2009                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
2010                                for (String label : field.getLabel().asSet()) {
2011                                    el.setAttribute("label", label);
2012                                }
2013                                for (String desc : field.getDesc().asSet()) {
2014                                    el.setAttribute("desc", desc);
2015                                }
2016                                for (String type : field.getType().asSet()) {
2017                                    el.setAttribute("type", type);
2018                                }
2019                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2020                                if (validate != null) el.addChild(validate);
2021                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
2022                            }
2023                        }
2024                    }
2025                }
2026            }
2027
2028            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
2029                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
2030                    super(binding);
2031                    binding.row.setOnClickListener((v) -> {
2032                        binding.checkbox.toggle();
2033                    });
2034                    binding.checkbox.setOnCheckedChangeListener(this);
2035                }
2036                protected Element mValue = null;
2037
2038                @Override
2039                public void bind(Item item) {
2040                    Field field = (Field) item;
2041                    binding.label.setText(field.getLabel().or(""));
2042                    setTextOrHide(binding.desc, field.getDesc());
2043                    mValue = field.getValue();
2044                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
2045                }
2046
2047                @Override
2048                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
2049                    if (mValue == null) return;
2050
2051                    mValue.setContent(isChecked ? "true" : "false");
2052                }
2053            }
2054
2055            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
2056                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
2057                    super(binding);
2058                    binding.search.addTextChangedListener(this);
2059                }
2060                protected Field field = null;
2061                Set<String> filteredValues;
2062                List<Option> options = new ArrayList<>();
2063                protected ArrayAdapter<Option> adapter;
2064                protected boolean open;
2065                protected boolean multi;
2066                protected int textColor = -1;
2067
2068                @Override
2069                public void bind(Item item) {
2070                    ViewGroup.LayoutParams layout = binding.list.getLayoutParams();
2071                    final float density = xmppConnectionService.getResources().getDisplayMetrics().density;
2072                    if (fillableFieldCount > 1) {
2073                        layout.height = (int) (density * 200);
2074                    } else {
2075                        layout.height = (int) Math.max(density * 200, xmppConnectionService.getResources().getDisplayMetrics().heightPixels / 2);
2076                    }
2077                    binding.list.setLayoutParams(layout);
2078
2079                    field = (Field) item;
2080                    setTextOrHide(binding.label, field.getLabel());
2081                    setTextOrHide(binding.desc, field.getDesc());
2082
2083                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2084                    if (field.error != null) {
2085                        binding.desc.setVisibility(View.VISIBLE);
2086                        binding.desc.setText(field.error);
2087                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2088                    } else {
2089                        binding.desc.setTextColor(textColor);
2090                    }
2091
2092                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2093                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
2094                    setupInputType(field.el, binding.search, null);
2095
2096                    multi = field.getType().equals(Optional.of("list-multi"));
2097                    if (multi) {
2098                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE);
2099                    } else {
2100                        binding.list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
2101                    }
2102
2103                    options = field.getOptions();
2104                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
2105                        Set<String> values = new HashSet<>();
2106                        if (multi) {
2107                            values.addAll(field.getValues());
2108                            for (final String value : field.getValues()) {
2109                                if (filteredValues.contains(value)) {
2110                                    values.remove(value);
2111                                }
2112                            }
2113                        }
2114
2115                        SparseBooleanArray positions = binding.list.getCheckedItemPositions();
2116                        for (int i = 0; i < positions.size(); i++) {
2117                            if (positions.valueAt(i)) values.add(adapter.getItem(positions.keyAt(i)).getValue());
2118                        }
2119                        field.setValues(values);
2120
2121                        if (!multi && open) binding.search.setText(String.join("\n", field.getValues()));
2122                    });
2123                    search("");
2124                }
2125
2126                @Override
2127                public void afterTextChanged(Editable s) {
2128                    if (!multi && open) field.setValues(List.of(s.toString()));
2129                    search(s.toString());
2130                }
2131
2132                @Override
2133                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2134
2135                @Override
2136                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2137
2138                protected void search(String s) {
2139                    List<Option> filteredOptions;
2140                    final String q = s.replaceAll("\\W", "").toLowerCase();
2141                    if (q == null || q.equals("")) {
2142                        filteredOptions = options;
2143                    } else {
2144                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
2145                    }
2146                    filteredValues = filteredOptions.stream().map(o -> o.getValue()).collect(Collectors.toSet());
2147                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
2148                    binding.list.setAdapter(adapter);
2149
2150                    for (final String value : field.getValues()) {
2151                        int checkedPos = filteredOptions.indexOf(new Option(value, ""));
2152                        if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
2153                    }
2154                }
2155            }
2156
2157            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
2158                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
2159                    super(binding);
2160                    binding.open.addTextChangedListener(this);
2161                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
2162                        @Override
2163                        public View getView(int position, View convertView, ViewGroup parent) {
2164                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
2165                            v.setId(position);
2166                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
2167                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
2168                            return v;
2169                        }
2170                    };
2171                }
2172                protected Element mValue = null;
2173                protected ArrayAdapter<Option> options;
2174                protected int textColor = -1;
2175
2176                @Override
2177                public void bind(Item item) {
2178                    Field field = (Field) item;
2179                    setTextOrHide(binding.label, field.getLabel());
2180                    setTextOrHide(binding.desc, field.getDesc());
2181
2182                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2183                    if (field.error != null) {
2184                        binding.desc.setVisibility(View.VISIBLE);
2185                        binding.desc.setText(field.error);
2186                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2187                    } else {
2188                        binding.desc.setTextColor(textColor);
2189                    }
2190
2191                    mValue = field.getValue();
2192
2193                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2194                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2195                    binding.open.setText(mValue.getContent());
2196                    setupInputType(field.el, binding.open, null);
2197
2198                    options.clear();
2199                    List<Option> theOptions = field.getOptions();
2200                    options.addAll(theOptions);
2201
2202                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
2203                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
2204                    float maxColumnWidth = theOptions.stream().map((x) ->
2205                        StaticLayout.getDesiredWidth(x.toString(), paint)
2206                    ).max(Float::compare).orElse(new Float(0.0));
2207                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
2208                        binding.radios.setNumColumns(theOptions.size());
2209                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
2210                        binding.radios.setNumColumns(theOptions.size() / 2);
2211                    } else {
2212                        binding.radios.setNumColumns(1);
2213                    }
2214                    binding.radios.setAdapter(options);
2215                }
2216
2217                @Override
2218                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
2219                    if (mValue == null) return;
2220
2221                    if (isChecked) {
2222                        mValue.setContent(options.getItem(radio.getId()).getValue());
2223                        binding.open.setText(mValue.getContent());
2224                    }
2225                    options.notifyDataSetChanged();
2226                }
2227
2228                @Override
2229                public void afterTextChanged(Editable s) {
2230                    if (mValue == null) return;
2231
2232                    mValue.setContent(s.toString());
2233                    options.notifyDataSetChanged();
2234                }
2235
2236                @Override
2237                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2238
2239                @Override
2240                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2241            }
2242
2243            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
2244                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
2245                    super(binding);
2246                    binding.spinner.setOnItemSelectedListener(this);
2247                }
2248                protected Element mValue = null;
2249
2250                @Override
2251                public void bind(Item item) {
2252                    Field field = (Field) item;
2253                    setTextOrHide(binding.label, field.getLabel());
2254                    binding.spinner.setPrompt(field.getLabel().or(""));
2255                    setTextOrHide(binding.desc, field.getDesc());
2256
2257                    mValue = field.getValue();
2258
2259                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
2260                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
2261                    options.addAll(field.getOptions());
2262
2263                    binding.spinner.setAdapter(options);
2264                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
2265                }
2266
2267                @Override
2268                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
2269                    Option o = (Option) parent.getItemAtPosition(pos);
2270                    if (mValue == null) return;
2271
2272                    mValue.setContent(o == null ? "" : o.getValue());
2273                }
2274
2275                @Override
2276                public void onNothingSelected(AdapterView<?> parent) {
2277                    mValue.setContent("");
2278                }
2279            }
2280
2281            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
2282                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
2283                    super(binding);
2284                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
2285                        protected int height = 0;
2286
2287                        @Override
2288                        public View getView(int position, View convertView, ViewGroup parent) {
2289                            Button v = (Button) super.getView(position, convertView, parent);
2290                            v.setOnClickListener((view) -> {
2291                                mValue.setContent(getItem(position).getValue());
2292                                execute();
2293                                loading = true;
2294                            });
2295
2296                            final SVG icon = getItem(position).getIcon();
2297                            if (icon != null) {
2298                                 final Element iconEl = getItem(position).getIconEl();
2299                                 if (height < 1) {
2300                                     v.measure(0, 0);
2301                                     height = v.getMeasuredHeight();
2302                                 }
2303                                 if (height < 1) return v;
2304                                 if (mediaSelector) {
2305                                     final Drawable d = getDrawableForSVG(icon, iconEl, height * 4);
2306                                     if (d != null) {
2307                                         final int boundsHeight = 35 + (int)((height * 4) / xmppConnectionService.getResources().getDisplayMetrics().density);
2308                                         d.setBounds(0, 0, d.getIntrinsicWidth(), boundsHeight);
2309                                     }
2310                                     v.setCompoundDrawables(null, d, null, null);
2311                                 } else {
2312                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(getDrawableForSVG(icon, iconEl, height), null, null, null);
2313                                 }
2314                            }
2315
2316                            return v;
2317                        }
2318                    };
2319                }
2320                protected Element mValue = null;
2321                protected ArrayAdapter<Option> options;
2322                protected Option defaultOption = null;
2323                protected boolean mediaSelector = false;
2324                protected int textColor = -1;
2325
2326                @Override
2327                public void bind(Item item) {
2328                    Field field = (Field) item;
2329                    setTextOrHide(binding.label, field.getLabel());
2330                    setTextOrHide(binding.desc, field.getDesc());
2331
2332                    if (textColor == -1) textColor = binding.desc.getCurrentTextColor();
2333                    if (field.error != null) {
2334                        binding.desc.setVisibility(View.VISIBLE);
2335                        binding.desc.setText(field.error);
2336                        binding.desc.setTextColor(com.google.android.material.R.attr.colorError);
2337                    } else {
2338                        binding.desc.setTextColor(textColor);
2339                    }
2340
2341                    mValue = field.getValue();
2342                    mediaSelector = field.el.findChild("media-selector", "https://ns.cheogram.com/") != null;
2343
2344                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2345                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
2346                    binding.openButton.setOnClickListener((view) -> {
2347                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
2348                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
2349                        builder.setPositiveButton(R.string.action_execute, null);
2350                        if (field.getDesc().isPresent()) {
2351                            dialogBinding.inputLayout.setHint(field.getDesc().get());
2352                        }
2353                        dialogBinding.inputEditText.requestFocus();
2354                        dialogBinding.inputEditText.getText().append(mValue.getContent());
2355                        builder.setView(dialogBinding.getRoot());
2356                        builder.setNegativeButton(R.string.cancel, null);
2357                        final AlertDialog dialog = builder.create();
2358                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
2359                        dialog.show();
2360                        View.OnClickListener clickListener = v -> {
2361                            String value = dialogBinding.inputEditText.getText().toString();
2362                            mValue.setContent(value);
2363                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2364                            dialog.dismiss();
2365                            execute();
2366                            loading = true;
2367                        };
2368                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
2369                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2370                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2371                            dialog.dismiss();
2372                        }));
2373                        dialog.setCanceledOnTouchOutside(false);
2374                        dialog.setOnDismissListener(dialog1 -> {
2375                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2376                        });
2377                    });
2378
2379                    options.clear();
2380                    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();
2381
2382                    defaultOption = null;
2383                    for (Option option : theOptions) {
2384                        if (option.getValue().equals(mValue.getContent())) {
2385                            defaultOption = option;
2386                            break;
2387                        }
2388                    }
2389                    if (defaultOption == null && !mValue.getContent().equals("")) {
2390                        // Synthesize default option for custom value
2391                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2392                    }
2393                    if (defaultOption == null) {
2394                        binding.defaultButton.setVisibility(View.GONE);
2395                    } else {
2396                        theOptions.remove(defaultOption);
2397                        binding.defaultButton.setVisibility(View.VISIBLE);
2398
2399                        final SVG defaultIcon = defaultOption.getIcon();
2400                        if (defaultIcon != null) {
2401                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2402                             int height = (int)(display.heightPixels*display.density/4);
2403                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, getDrawableForSVG(defaultIcon, defaultOption.getIconEl(), height), null, null);
2404                        }
2405
2406                        binding.defaultButton.setText(defaultOption.toString());
2407                        binding.defaultButton.setOnClickListener((view) -> {
2408                            mValue.setContent(defaultOption.getValue());
2409                            execute();
2410                            loading = true;
2411                        });
2412                    }
2413
2414                    options.addAll(theOptions);
2415                    binding.buttons.setAdapter(options);
2416                }
2417            }
2418
2419            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2420                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2421                    super(binding);
2422                    binding.textinput.addTextChangedListener(this);
2423                }
2424                protected Field field = null;
2425
2426                @Override
2427                public void bind(Item item) {
2428                    field = (Field) item;
2429                    binding.textinputLayout.setHint(field.getLabel().or(""));
2430
2431                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2432                    for (String desc : field.getDesc().asSet()) {
2433                        binding.textinputLayout.setHelperText(desc);
2434                    }
2435
2436                    binding.textinputLayout.setErrorEnabled(field.error != null);
2437                    if (field.error != null) binding.textinputLayout.setError(field.error);
2438
2439                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2440                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2441                    if (suffixLabel == null) {
2442                        binding.textinputLayout.setSuffixText("");
2443                    } else {
2444                        binding.textinputLayout.setSuffixText(suffixLabel);
2445                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2446                    }
2447
2448                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2449                    binding.textinputLayout.setPrefixText(prefixLabel == null ? "" : prefixLabel);
2450
2451                    binding.textinput.setText(String.join("\n", field.getValues()));
2452                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2453                }
2454
2455                @Override
2456                public void afterTextChanged(Editable s) {
2457                    if (field == null) return;
2458
2459                    field.setValues(List.of(s.toString().split("\n")));
2460                }
2461
2462                @Override
2463                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2464
2465                @Override
2466                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2467            }
2468
2469            class SliderFieldViewHolder extends ViewHolder<CommandSliderFieldBinding> {
2470                public SliderFieldViewHolder(CommandSliderFieldBinding binding) { super(binding); }
2471                protected Field field = null;
2472
2473                @Override
2474                public void bind(Item item) {
2475                    field = (Field) item;
2476                    setTextOrHide(binding.label, field.getLabel());
2477                    setTextOrHide(binding.desc, field.getDesc());
2478                    final Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2479                    final String datatype = validate == null ? null : validate.getAttribute("datatype");
2480                    final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2481                    // NOTE: range also implies open, so we don't have to be bound by the options strictly
2482                    // Also, we don't have anywhere to put labels so we show only values, which might sometimes be bad...
2483                    Float min = null;
2484                    try { min = range.getAttribute("min") == null ? null : Float.valueOf(range.getAttribute("min")); } catch (NumberFormatException e) { }
2485                    Float max = null;
2486                    try { max = range.getAttribute("max") == null ? null : Float.valueOf(range.getAttribute("max"));  } catch (NumberFormatException e) { }
2487
2488                    List<Float> options = field.getOptions().stream().map(o -> Float.valueOf(o.getValue())).collect(Collectors.toList());
2489                    Collections.sort(options);
2490                    if (options.size() > 0) {
2491                        // min/max should be on the range, but if you have options and didn't put them on the range we can imply
2492                        if (min == null) min = options.get(0);
2493                        if (max == null) max = options.get(options.size()-1);
2494                    }
2495
2496                    if (field.getValues().size() > 0) {
2497                        binding.slider.setValue(Float.valueOf(field.getValue().getContent()));
2498                    } else {
2499                        binding.slider.setValue(min == null ? Float.MIN_VALUE : min);
2500                    }
2501                    binding.slider.setValueFrom(min == null ? Float.MIN_VALUE : min);
2502                    binding.slider.setValueTo(max == null ? Float.MAX_VALUE : max);
2503                    if ("xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype)) {
2504                        binding.slider.setStepSize(1);
2505                    } else {
2506                        binding.slider.setStepSize(0);
2507                    }
2508
2509                    if (options.size() > 0) {
2510                        float step = -1;
2511                        Float prev = null;
2512                        for (final Float option : options) {
2513                            if (prev != null) {
2514                                float nextStep = option - prev;
2515                                if (step > 0 && step != nextStep) {
2516                                    step = -1;
2517                                    break;
2518                                }
2519                                step = nextStep;
2520                            }
2521                            prev = option;
2522                        }
2523                        if (step > 0) binding.slider.setStepSize(step);
2524                    }
2525
2526                    binding.slider.addOnChangeListener((slider, value, fromUser) -> {
2527                        field.setValues(List.of(new DecimalFormat().format(value)));
2528                    });
2529                }
2530            }
2531
2532            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2533                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2534                protected String boundUrl = "";
2535
2536                @Override
2537                public void bind(Item oob) {
2538                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2539                    binding.webview.getSettings().setJavaScriptEnabled(true);
2540                    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");
2541                    binding.webview.getSettings().setDatabaseEnabled(true);
2542                    binding.webview.getSettings().setDomStorageEnabled(true);
2543                    binding.webview.setWebChromeClient(new WebChromeClient() {
2544                        @Override
2545                        public void onProgressChanged(WebView view, int newProgress) {
2546                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2547                            binding.progressbar.setProgress(newProgress);
2548                        }
2549                    });
2550                    binding.webview.setWebViewClient(new WebViewClient() {
2551                        @Override
2552                        public void onPageFinished(WebView view, String url) {
2553                            super.onPageFinished(view, url);
2554                            mTitle = view.getTitle();
2555                            ConversationPagerAdapter.this.notifyDataSetChanged();
2556                        }
2557                    });
2558                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2559                    if (!boundUrl.equals(url)) {
2560                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2561                        binding.webview.loadUrl(url);
2562                        boundUrl = url;
2563                    }
2564                }
2565
2566                class JsObject {
2567                    @JavascriptInterface
2568                    public void execute() { execute("execute"); }
2569
2570                    @JavascriptInterface
2571                    public void execute(String action) {
2572                        getView().post(() -> {
2573                            actionToWebview = null;
2574                            if(CommandSession.this.execute(action)) {
2575                                removeSession(CommandSession.this);
2576                            }
2577                        });
2578                    }
2579
2580                    @JavascriptInterface
2581                    public void preventDefault() {
2582                        actionToWebview = binding.webview;
2583                    }
2584                }
2585            }
2586
2587            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2588                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2589
2590                @Override
2591                public void bind(Item item) {
2592                    binding.text.setVisibility(loadingHasBeenLong ? View.VISIBLE : View.GONE);
2593                }
2594            }
2595
2596            class Item {
2597                protected Element el;
2598                protected int viewType;
2599                protected String error = null;
2600
2601                Item(Element el, int viewType) {
2602                    this.el = el;
2603                    this.viewType = viewType;
2604                }
2605
2606                public boolean validate() {
2607                    error = null;
2608                    return true;
2609                }
2610            }
2611
2612            class Field extends Item {
2613                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2614
2615                @Override
2616                public boolean validate() {
2617                    if (!super.validate()) return false;
2618                    if (el.findChild("required", "jabber:x:data") == null) return true;
2619                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2620
2621                    error = "this value is required";
2622                    return false;
2623                }
2624
2625                public String getVar() {
2626                    return el.getAttribute("var");
2627                }
2628
2629                public Optional<String> getType() {
2630                    return Optional.fromNullable(el.getAttribute("type"));
2631                }
2632
2633                public Optional<String> getLabel() {
2634                    String label = el.getAttribute("label");
2635                    if (label == null) label = getVar();
2636                    return Optional.fromNullable(label);
2637                }
2638
2639                public Optional<String> getDesc() {
2640                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2641                }
2642
2643                public Element getValue() {
2644                    Element value = el.findChild("value", "jabber:x:data");
2645                    if (value == null) {
2646                        value = el.addChild("value", "jabber:x:data");
2647                    }
2648                    return value;
2649                }
2650
2651                public void setValues(Collection<String> values) {
2652                    for(Element child : el.getChildren()) {
2653                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2654                            el.removeChild(child);
2655                        }
2656                    }
2657
2658                    for (String value : values) {
2659                        el.addChild("value", "jabber:x:data").setContent(value);
2660                    }
2661                }
2662
2663                public List<String> getValues() {
2664                    List<String> values = new ArrayList<>();
2665                    for(Element child : el.getChildren()) {
2666                        if ("value".equals(child.getName()) && "jabber:x:data".equals(child.getNamespace())) {
2667                            values.add(child.getContent());
2668                        }
2669                    }
2670                    return values;
2671                }
2672
2673                public List<Option> getOptions() {
2674                    return Option.forField(el);
2675                }
2676            }
2677
2678            class Cell extends Item {
2679                protected Field reported;
2680
2681                Cell(Field reported, Element item) {
2682                    super(item, TYPE_RESULT_CELL);
2683                    this.reported = reported;
2684                }
2685            }
2686
2687            protected Field mkField(Element el) {
2688                int viewType = -1;
2689
2690                String formType = responseElement.getAttribute("type");
2691                if (formType != null) {
2692                    String fieldType = el.getAttribute("type");
2693                    if (fieldType == null) fieldType = "text-single";
2694
2695                    if (formType.equals("result") || fieldType.equals("fixed")) {
2696                        viewType = TYPE_RESULT_FIELD;
2697                    } else if (formType.equals("form")) {
2698                        final Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2699                        final String datatype = validate == null ? null : validate.getAttribute("datatype");
2700                        final Element range = validate == null ? null : validate.findChild("range", "http://jabber.org/protocol/xdata-validate");
2701                        if (fieldType.equals("boolean")) {
2702                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1) {
2703                                viewType = TYPE_BUTTON_GRID_FIELD;
2704                            } else {
2705                                viewType = TYPE_CHECKBOX_FIELD;
2706                            }
2707                        } else if (
2708                            range != null && range.getAttribute("min") != null && range.getAttribute("max") != null && (
2709                                "xs:integer".equals(datatype) || "xs:int".equals(datatype) || "xs:long".equals(datatype) || "xs:short".equals(datatype) || "xs:byte".equals(datatype) ||
2710                                "xs:decimal".equals(datatype) || "xs:double".equals(datatype)
2711                            )
2712                        ) {
2713                            // has a range and is numeric, use a slider
2714                            viewType = TYPE_SLIDER_FIELD;
2715                        } else if (fieldType.equals("list-single")) {
2716                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 1 && Option.forField(el).size() < 50) {
2717                                viewType = TYPE_BUTTON_GRID_FIELD;
2718                            } else if (Option.forField(el).size() > 9) {
2719                                viewType = TYPE_SEARCH_LIST_FIELD;
2720                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2721                                viewType = TYPE_RADIO_EDIT_FIELD;
2722                            } else {
2723                                viewType = TYPE_SPINNER_FIELD;
2724                            }
2725                        } else if (fieldType.equals("list-multi")) {
2726                            viewType = TYPE_SEARCH_LIST_FIELD;
2727                        } else {
2728                            viewType = TYPE_TEXT_FIELD;
2729                        }
2730                    }
2731
2732                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2733                }
2734
2735                return null;
2736            }
2737
2738            protected Item mkItem(Element el, int pos) {
2739                int viewType = TYPE_ERROR;
2740
2741                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2742                    if (el.getName().equals("note")) {
2743                        viewType = TYPE_NOTE;
2744                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2745                        viewType = TYPE_WEB;
2746                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2747                        viewType = TYPE_NOTE;
2748                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2749                        Field field = mkField(el);
2750                        if (field != null) {
2751                            items.put(pos, field);
2752                            return field;
2753                        }
2754                    }
2755                }
2756
2757                Item item = new Item(el, viewType);
2758                items.put(pos, item);
2759                return item;
2760            }
2761
2762            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2763                protected Context ctx;
2764
2765                public ActionsAdapter(Context ctx) {
2766                    super(ctx, R.layout.simple_list_item);
2767                    this.ctx = ctx;
2768                }
2769
2770                @Override
2771                public View getView(int position, View convertView, ViewGroup parent) {
2772                    View v = super.getView(position, convertView, parent);
2773                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2774                    tv.setGravity(Gravity.CENTER);
2775                    tv.setText(getItem(position).second);
2776                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2777                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2778                    final var colors = MaterialColors.getColorRoles(ctx, UIHelper.getColorForName(getItem(position).first));
2779                    tv.setTextColor(colors.getOnAccent());
2780                    tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(ctx, colors.getAccent()));
2781                    return v;
2782                }
2783
2784                public int getPosition(String s) {
2785                    for(int i = 0; i < getCount(); i++) {
2786                        if (getItem(i).first.equals(s)) return i;
2787                    }
2788                    return -1;
2789                }
2790
2791                public int countProceed() {
2792                    int count = 0;
2793                    for(int i = 0; i < getCount(); i++) {
2794                        if (!"cancel".equals(getItem(i).first) && !"prev".equals(getItem(i).first)) count++;
2795                    }
2796                    return count;
2797                }
2798
2799                public int countExceptCancel() {
2800                    int count = 0;
2801                    for(int i = 0; i < getCount(); i++) {
2802                        if (!getItem(i).first.equals("cancel")) count++;
2803                    }
2804                    return count;
2805                }
2806
2807                public void clearProceed() {
2808                    Pair<String,String> cancelItem = null;
2809                    Pair<String,String> prevItem = null;
2810                    for(int i = 0; i < getCount(); i++) {
2811                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2812                        if (getItem(i).first.equals("prev")) prevItem = getItem(i);
2813                    }
2814                    clear();
2815                    if (cancelItem != null) add(cancelItem);
2816                    if (prevItem != null) add(prevItem);
2817                }
2818            }
2819
2820            final int TYPE_ERROR = 1;
2821            final int TYPE_NOTE = 2;
2822            final int TYPE_WEB = 3;
2823            final int TYPE_RESULT_FIELD = 4;
2824            final int TYPE_TEXT_FIELD = 5;
2825            final int TYPE_CHECKBOX_FIELD = 6;
2826            final int TYPE_SPINNER_FIELD = 7;
2827            final int TYPE_RADIO_EDIT_FIELD = 8;
2828            final int TYPE_RESULT_CELL = 9;
2829            final int TYPE_PROGRESSBAR = 10;
2830            final int TYPE_SEARCH_LIST_FIELD = 11;
2831            final int TYPE_ITEM_CARD = 12;
2832            final int TYPE_BUTTON_GRID_FIELD = 13;
2833            final int TYPE_SLIDER_FIELD = 14;
2834
2835            protected boolean executing = false;
2836            protected boolean loading = false;
2837            protected boolean loadingHasBeenLong = false;
2838            protected Timer loadingTimer = new Timer();
2839            protected String mTitle;
2840            protected String mNode;
2841            protected CommandPageBinding mBinding = null;
2842            protected IqPacket response = null;
2843            protected Element responseElement = null;
2844            protected boolean expectingRemoval = false;
2845            protected List<Field> reported = null;
2846            protected SparseArray<Item> items = new SparseArray<>();
2847            protected XmppConnectionService xmppConnectionService;
2848            protected ActionsAdapter actionsAdapter;
2849            protected GridLayoutManager layoutManager;
2850            protected WebView actionToWebview = null;
2851            protected int fillableFieldCount = 0;
2852            protected IqPacket pendingResponsePacket = null;
2853            protected boolean waitingForRefresh = false;
2854
2855            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2856                loading();
2857                mTitle = title;
2858                mNode = node;
2859                this.xmppConnectionService = xmppConnectionService;
2860                if (mPager != null) setupLayoutManager();
2861            }
2862
2863            public String getTitle() {
2864                return mTitle;
2865            }
2866
2867            public String getNode() {
2868                return mNode;
2869            }
2870
2871            public void updateWithResponse(final IqPacket iq) {
2872                if (getView() != null && getView().isAttachedToWindow()) {
2873                    getView().post(() -> updateWithResponseUiThread(iq));
2874                } else {
2875                    pendingResponsePacket = iq;
2876                }
2877            }
2878
2879            protected void updateWithResponseUiThread(final IqPacket iq) {
2880                Timer oldTimer = this.loadingTimer;
2881                this.loadingTimer = new Timer();
2882                oldTimer.cancel();
2883                this.executing = false;
2884                this.loading = false;
2885                this.loadingHasBeenLong = false;
2886                this.responseElement = null;
2887                this.fillableFieldCount = 0;
2888                this.reported = null;
2889                this.response = iq;
2890                this.items.clear();
2891                this.actionsAdapter.clear();
2892                layoutManager.setSpanCount(1);
2893
2894                boolean actionsCleared = false;
2895                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2896                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2897                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2898                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2899                    }
2900
2901                    if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2902                        xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2903                    }
2904
2905                    Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2906                    if (actions != null) {
2907                        for (Element action : actions.getChildren()) {
2908                            if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2909                            if ("execute".equals(action.getName())) continue;
2910
2911                            actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2912                        }
2913                    }
2914
2915                    for (Element el : command.getChildren()) {
2916                        if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2917                            Data form = Data.parse(el);
2918                            String title = form.getTitle();
2919                            if (title != null) {
2920                                mTitle = title;
2921                                ConversationPagerAdapter.this.notifyDataSetChanged();
2922                            }
2923
2924                            if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2925                                this.responseElement = el;
2926                                setupReported(el.findChild("reported", "jabber:x:data"));
2927                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2928                            }
2929
2930                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2931                            if (actionList != null) {
2932                                actionsAdapter.clear();
2933
2934                                for (Option action : actionList.getOptions()) {
2935                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2936                                }
2937                            }
2938
2939                            eu.siacs.conversations.xmpp.forms.Field fillableField = null;
2940                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2941                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2942                                    fillableField = field;
2943                                    fillableFieldCount++;
2944                                }
2945                            }
2946
2947                            if (fillableFieldCount == 1 && actionsAdapter.countProceed() < 2 && (("list-single".equals(fillableField.getType()) && Option.forField(fillableField).size() < 50) || ("boolean".equals(fillableField.getType()) && fillableField.getValue() == null))) {
2948                                actionsCleared = true;
2949                                actionsAdapter.clearProceed();
2950                            }
2951                            break;
2952                        }
2953                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2954                            String url = el.findChildContent("url", "jabber:x:oob");
2955                            if (url != null) {
2956                                String scheme = Uri.parse(url).getScheme();
2957                                if (scheme.equals("http") || scheme.equals("https")) {
2958                                    this.responseElement = el;
2959                                    break;
2960                                }
2961                                if (scheme.equals("xmpp")) {
2962                                    expectingRemoval = true;
2963                                    final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2964                                    intent.setAction(Intent.ACTION_VIEW);
2965                                    intent.setData(Uri.parse(url));
2966                                    getView().getContext().startActivity(intent);
2967                                    break;
2968                                }
2969                            }
2970                        }
2971                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2972                            this.responseElement = el;
2973                            break;
2974                        }
2975                    }
2976
2977                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2978                        if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2979                            if (xmppConnectionService.isOnboarding()) {
2980                                if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2981                                    xmppConnectionService.deleteAccount(getAccount());
2982                                } else {
2983                                    if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2984                                        removeSession(this);
2985                                        return;
2986                                    } else {
2987                                        xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2988                                        xmppConnectionService.deleteAccount(getAccount());
2989                                    }
2990                                }
2991                            }
2992                            xmppConnectionService.archiveConversation(Conversation.this);
2993                        }
2994
2995                        expectingRemoval = true;
2996                        removeSession(this);
2997                        return;
2998                    }
2999
3000                    if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
3001                        // No actions have been given, but we are not done?
3002                        // This is probably a spec violation, but we should do *something*
3003                        actionsAdapter.add(Pair.create("execute", "execute"));
3004                    }
3005
3006                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
3007                        if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
3008                            actionsAdapter.add(Pair.create("close", "close"));
3009                        } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
3010                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3011                        }
3012                    }
3013                }
3014
3015                if (actionsAdapter.isEmpty()) {
3016                    actionsAdapter.add(Pair.create("close", "close"));
3017                }
3018
3019                actionsAdapter.sort((x, y) -> {
3020                    if (x.first.equals("cancel")) return -1;
3021                    if (y.first.equals("cancel")) return 1;
3022                    if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
3023                    if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
3024                    return 0;
3025                });
3026
3027                Data dataForm = null;
3028                if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
3029                if (mNode.equals("jabber:iq:register") &&
3030                    xmppConnectionService.getPreferences().contains("onboarding_action") &&
3031                    dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
3032
3033
3034                    dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
3035                    execute();
3036                }
3037                xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3038                notifyDataSetChanged();
3039            }
3040
3041            protected void setupReported(Element el) {
3042                if (el == null) {
3043                    reported = null;
3044                    return;
3045                }
3046
3047                reported = new ArrayList<>();
3048                for (Element fieldEl : el.getChildren()) {
3049                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
3050                    reported.add(mkField(fieldEl));
3051                }
3052            }
3053
3054            @Override
3055            public int getItemCount() {
3056                if (loading) return 1;
3057                if (response == null) return 0;
3058                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
3059                    int i = 0;
3060                    for (Element el : responseElement.getChildren()) {
3061                        if (!el.getNamespace().equals("jabber:x:data")) continue;
3062                        if (el.getName().equals("title")) continue;
3063                        if (el.getName().equals("field")) {
3064                            String type = el.getAttribute("type");
3065                            if (type != null && type.equals("hidden")) continue;
3066                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3067                        }
3068
3069                        if (el.getName().equals("reported") || el.getName().equals("item")) {
3070                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3071                                if (el.getName().equals("reported")) continue;
3072                                i += 1;
3073                            } else {
3074                                if (reported != null) i += reported.size();
3075                            }
3076                            continue;
3077                        }
3078
3079                        i++;
3080                    }
3081                    return i;
3082                }
3083                return 1;
3084            }
3085
3086            public Item getItem(int position) {
3087                if (loading) return new Item(null, TYPE_PROGRESSBAR);
3088                if (items.get(position) != null) return items.get(position);
3089                if (response == null) return null;
3090
3091                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
3092                    if (responseElement.getNamespace().equals("jabber:x:data")) {
3093                        int i = 0;
3094                        for (Element el : responseElement.getChildren()) {
3095                            if (!el.getNamespace().equals("jabber:x:data")) continue;
3096                            if (el.getName().equals("title")) continue;
3097                            if (el.getName().equals("field")) {
3098                                String type = el.getAttribute("type");
3099                                if (type != null && type.equals("hidden")) continue;
3100                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
3101                            }
3102
3103                            if (el.getName().equals("reported") || el.getName().equals("item")) {
3104                                Cell cell = null;
3105
3106                                if (reported != null) {
3107                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
3108                                        if (el.getName().equals("reported")) continue;
3109                                        if (i == position) {
3110                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
3111                                            return items.get(position);
3112                                        }
3113                                    } else {
3114                                        if (reported.size() > position - i) {
3115                                            Field reportedField = reported.get(position - i);
3116                                            Element itemField = null;
3117                                            if (el.getName().equals("item")) {
3118                                                for (Element subel : el.getChildren()) {
3119                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
3120                                                       itemField = subel;
3121                                                       break;
3122                                                    }
3123                                                }
3124                                            }
3125                                            cell = new Cell(reportedField, itemField);
3126                                        } else {
3127                                            i += reported.size();
3128                                            continue;
3129                                        }
3130                                    }
3131                                }
3132
3133                                if (cell != null) {
3134                                    items.put(position, cell);
3135                                    return cell;
3136                                }
3137                            }
3138
3139                            if (i < position) {
3140                                i++;
3141                                continue;
3142                            }
3143
3144                            return mkItem(el, position);
3145                        }
3146                    }
3147                }
3148
3149                return mkItem(responseElement == null ? response : responseElement, position);
3150            }
3151
3152            @Override
3153            public int getItemViewType(int position) {
3154                return getItem(position).viewType;
3155            }
3156
3157            @Override
3158            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
3159                switch(viewType) {
3160                    case TYPE_ERROR: {
3161                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3162                        return new ErrorViewHolder(binding);
3163                    }
3164                    case TYPE_NOTE: {
3165                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3166                        return new NoteViewHolder(binding);
3167                    }
3168                    case TYPE_WEB: {
3169                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
3170                        return new WebViewHolder(binding);
3171                    }
3172                    case TYPE_RESULT_FIELD: {
3173                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
3174                        return new ResultFieldViewHolder(binding);
3175                    }
3176                    case TYPE_RESULT_CELL: {
3177                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
3178                        return new ResultCellViewHolder(binding);
3179                    }
3180                    case TYPE_ITEM_CARD: {
3181                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
3182                        return new ItemCardViewHolder(binding);
3183                    }
3184                    case TYPE_CHECKBOX_FIELD: {
3185                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
3186                        return new CheckboxFieldViewHolder(binding);
3187                    }
3188                    case TYPE_SEARCH_LIST_FIELD: {
3189                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
3190                        return new SearchListFieldViewHolder(binding);
3191                    }
3192                    case TYPE_RADIO_EDIT_FIELD: {
3193                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
3194                        return new RadioEditFieldViewHolder(binding);
3195                    }
3196                    case TYPE_SPINNER_FIELD: {
3197                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
3198                        return new SpinnerFieldViewHolder(binding);
3199                    }
3200                    case TYPE_BUTTON_GRID_FIELD: {
3201                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3202                        return new ButtonGridFieldViewHolder(binding);
3203                    }
3204                    case TYPE_TEXT_FIELD: {
3205                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3206                        return new TextFieldViewHolder(binding);
3207                    }
3208                    case TYPE_SLIDER_FIELD: {
3209                        CommandSliderFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_slider_field, container, false);
3210                        return new SliderFieldViewHolder(binding);
3211                    }
3212                    case TYPE_PROGRESSBAR: {
3213                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3214                        return new ProgressBarViewHolder(binding);
3215                    }
3216                    default:
3217                        if (expectingRemoval) {
3218                            CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3219                            return new NoteViewHolder(binding);
3220                        }
3221
3222                        throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3223                }
3224            }
3225
3226            @Override
3227            public void onBindViewHolder(ViewHolder viewHolder, int position) {
3228                viewHolder.bind(getItem(position));
3229            }
3230
3231            public View getView() {
3232                if (mBinding == null) return null;
3233                return mBinding.getRoot();
3234            }
3235
3236            public boolean validate() {
3237                int count = getItemCount();
3238                boolean isValid = true;
3239                for (int i = 0; i < count; i++) {
3240                    boolean oneIsValid = getItem(i).validate();
3241                    isValid = isValid && oneIsValid;
3242                }
3243                notifyDataSetChanged();
3244                return isValid;
3245            }
3246
3247            public boolean execute() {
3248                return execute("execute");
3249            }
3250
3251            public boolean execute(int actionPosition) {
3252                return execute(actionsAdapter.getItem(actionPosition).first);
3253            }
3254
3255            public synchronized boolean execute(String action) {
3256                if (!"cancel".equals(action) && executing) {
3257                    loadingHasBeenLong = true;
3258                    notifyDataSetChanged();
3259                    return false;
3260                }
3261                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3262
3263                if (response == null) return true;
3264                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3265                if (command == null) return true;
3266                String status = command.getAttribute("status");
3267                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3268
3269                if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3270                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3271                    return false;
3272                }
3273
3274                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3275                packet.setTo(response.getFrom());
3276                final Element c = packet.addChild("command", Namespace.COMMANDS);
3277                c.setAttribute("node", mNode);
3278                c.setAttribute("sessionid", command.getAttribute("sessionid"));
3279
3280                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3281                if (!action.equals("cancel") &&
3282                    !action.equals("prev") &&
3283                    responseElement != null &&
3284                    responseElement.getName().equals("x") &&
3285                    responseElement.getNamespace().equals("jabber:x:data") &&
3286                    formType != null && formType.equals("form")) {
3287
3288                    Data form = Data.parse(responseElement);
3289                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3290                    if (actionList != null) {
3291                        actionList.setValue(action);
3292                        c.setAttribute("action", "execute");
3293                    }
3294
3295                    if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3296                        if (form.getValue("gateway-jid") == null) {
3297                            xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3298                        } else {
3299                            xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3300                        }
3301                    }
3302
3303                    responseElement.setAttribute("type", "submit");
3304                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3305                    if (rsm != null) {
3306                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
3307                        max.setContent("1000");
3308                        rsm.addChild(max);
3309                    }
3310
3311                    c.addChild(responseElement);
3312                }
3313
3314                if (c.getAttribute("action") == null) c.setAttribute("action", action);
3315
3316                executing = true;
3317                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3318                    updateWithResponse(iq);
3319                }, 120L);
3320
3321                loading();
3322                return false;
3323            }
3324
3325            public void refresh() {
3326                synchronized(this) {
3327                    if (waitingForRefresh) notifyDataSetChanged();
3328                }
3329            }
3330
3331            protected void loading() {
3332                View v = getView();
3333                try {
3334                    loadingTimer.schedule(new TimerTask() {
3335                        @Override
3336                        public void run() {
3337                            View v2 = getView();
3338                            loading = true;
3339
3340                            try {
3341                                loadingTimer.schedule(new TimerTask() {
3342                                    @Override
3343                                    public void run() {
3344                                        loadingHasBeenLong = true;
3345                                        if (v == null && v2 == null) return;
3346                                        (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3347                                    }
3348                                }, 3000);
3349                            } catch (final IllegalStateException e) { }
3350
3351                            if (v == null && v2 == null) return;
3352                            (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3353                        }
3354                    }, 500);
3355                } catch (final IllegalStateException e) { }
3356            }
3357
3358            protected GridLayoutManager setupLayoutManager() {
3359                int spanCount = 1;
3360
3361                Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3362                if (reported != null) {
3363                    float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3364                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3365                    float tableHeaderWidth = reported.stream().reduce(
3366                        0f,
3367                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3368                        (a, b) -> a + b
3369                    );
3370
3371                    spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3372                }
3373
3374                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3375                    items.clear();
3376                    notifyDataSetChanged();
3377                }
3378
3379                layoutManager = new GridLayoutManager(ctx, spanCount);
3380                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3381                    @Override
3382                    public int getSpanSize(int position) {
3383                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3384                        return 1;
3385                    }
3386                });
3387                return layoutManager;
3388            }
3389
3390            protected void setBinding(CommandPageBinding b) {
3391                mBinding = b;
3392                // https://stackoverflow.com/a/32350474/8611
3393                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3394                    @Override
3395                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3396                        if(rv.getChildCount() > 0) {
3397                            int[] location = new int[2];
3398                            rv.getLocationOnScreen(location);
3399                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
3400                            if (childView instanceof ViewGroup) {
3401                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3402                            }
3403                            int action = e.getAction();
3404                            switch (action) {
3405                                case MotionEvent.ACTION_DOWN:
3406                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3407                                        rv.requestDisallowInterceptTouchEvent(true);
3408                                    }
3409                                case MotionEvent.ACTION_UP:
3410                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3411                                        rv.requestDisallowInterceptTouchEvent(true);
3412                                    }
3413                            }
3414                        }
3415
3416                        return false;
3417                    }
3418
3419                    @Override
3420                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3421
3422                    @Override
3423                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3424                });
3425                mBinding.form.setLayoutManager(setupLayoutManager());
3426                mBinding.form.setAdapter(this);
3427
3428                actionsAdapter = new ActionsAdapter(mBinding.getRoot().getContext());
3429                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
3430                    @Override
3431                    public void onChanged() {
3432                        if (mBinding == null) return;
3433
3434                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
3435                    }
3436
3437                    @Override
3438                    public void onInvalidated() {}
3439                });
3440                mBinding.actions.setAdapter(actionsAdapter);
3441                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3442                    if (execute(pos)) {
3443                        removeSession(CommandSession.this);
3444                    }
3445                });
3446
3447                actionsAdapter.notifyDataSetChanged();
3448
3449                if (pendingResponsePacket != null) {
3450                    final IqPacket pending = pendingResponsePacket;
3451                    pendingResponsePacket = null;
3452                    updateWithResponseUiThread(pending);
3453                }
3454            }
3455
3456            private Drawable getDrawableForSVG(SVG svg, Element svgElement, int size) {
3457               if (svgElement != null && svgElement.getChildren().size() == 1 && svgElement.getChildren().get(0).getName().equals("image"))  {
3458                   return getDrawableForUrl(svgElement.getChildren().get(0).getAttribute("href"));
3459               } else {
3460                   return xmppConnectionService.getFileBackend().drawSVG(svg, size);
3461               }
3462            }
3463
3464            private Drawable getDrawableForUrl(final String url) {
3465                final LruCache<String, Drawable> cache = xmppConnectionService.getDrawableCache();
3466                final HttpConnectionManager httpManager = xmppConnectionService.getHttpConnectionManager();
3467                final Drawable d = cache.get(url);
3468                if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) ((AnimatedImageDrawable) d).start();
3469                if (d == null) {
3470                    synchronized (CommandSession.this) {
3471                        waitingForRefresh = true;
3472                    }
3473                    int size = (int)(xmppConnectionService.getResources().getDisplayMetrics().density * 288);
3474                    Message dummy = new Message(Conversation.this, url, Message.ENCRYPTION_NONE);
3475                    dummy.setFileParams(new Message.FileParams(url));
3476                    httpManager.createNewDownloadConnection(dummy, true, (file) -> {
3477                        if (file == null) {
3478                            dummy.getTransferable().start();
3479                        } else {
3480                            try {
3481                                xmppConnectionService.getFileBackend().getThumbnail(file, xmppConnectionService.getResources(), size, false, url);
3482                            } catch (final Exception e) { }
3483                        }
3484                    });
3485                }
3486                return d;
3487            }
3488
3489            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3490                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3491                setBinding(binding);
3492                return binding.getRoot();
3493            }
3494
3495            // https://stackoverflow.com/a/36037991/8611
3496            private View findViewAt(ViewGroup viewGroup, float x, float y) {
3497                for(int i = 0; i < viewGroup.getChildCount(); i++) {
3498                    View child = viewGroup.getChildAt(i);
3499                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3500                        View foundView = findViewAt((ViewGroup) child, x, y);
3501                        if (foundView != null && foundView.isShown()) {
3502                            return foundView;
3503                        }
3504                    } else {
3505                        int[] location = new int[2];
3506                        child.getLocationOnScreen(location);
3507                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3508                        if (rect.contains((int)x, (int)y)) {
3509                            return child;
3510                        }
3511                    }
3512                }
3513
3514                return null;
3515            }
3516        }
3517
3518        class MucConfigSession extends CommandSession {
3519            MucConfigSession(XmppConnectionService xmppConnectionService) {
3520                super("Configure Channel", null, xmppConnectionService);
3521            }
3522
3523            @Override
3524            protected void updateWithResponseUiThread(final IqPacket iq) {
3525                Timer oldTimer = this.loadingTimer;
3526                this.loadingTimer = new Timer();
3527                oldTimer.cancel();
3528                this.executing = false;
3529                this.loading = false;
3530                this.loadingHasBeenLong = false;
3531                this.responseElement = null;
3532                this.fillableFieldCount = 0;
3533                this.reported = null;
3534                this.response = iq;
3535                this.items.clear();
3536                this.actionsAdapter.clear();
3537                layoutManager.setSpanCount(1);
3538
3539                final Element query = iq.findChild("query", "http://jabber.org/protocol/muc#owner");
3540                if (iq.getType() == IqPacket.TYPE.RESULT && query != null) {
3541                    final Data form = Data.parse(query.findChild("x", "jabber:x:data"));
3542                    final String title = form.getTitle();
3543                    if (title != null) {
3544                        mTitle = title;
3545                        ConversationPagerAdapter.this.notifyDataSetChanged();
3546                    }
3547
3548                    this.responseElement = form;
3549                    setupReported(form.findChild("reported", "jabber:x:data"));
3550                    if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
3551
3552                    if (actionsAdapter.countExceptCancel() < 1) {
3553                        actionsAdapter.add(Pair.create("save", "Save"));
3554                    }
3555
3556                    if (actionsAdapter.getPosition("cancel") < 0) {
3557                        actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
3558                    }
3559                } else if (iq.getType() == IqPacket.TYPE.RESULT) {
3560                    expectingRemoval = true;
3561                    removeSession(this);
3562                    return;
3563                } else {
3564                    actionsAdapter.add(Pair.create("close", "close"));
3565                }
3566
3567                notifyDataSetChanged();
3568            }
3569
3570            @Override
3571            public synchronized boolean execute(String action) {
3572                if ("cancel".equals(action)) {
3573                    final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3574                    packet.setTo(response.getFrom());
3575                    final Element form = packet
3576                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3577                        .addChild("x", "jabber:x:data");
3578                    form.setAttribute("type", "cancel");
3579                    xmppConnectionService.sendIqPacket(getAccount(), packet, null);
3580                    return true;
3581                }
3582
3583                if (!"save".equals(action)) return true;
3584
3585                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3586                packet.setTo(response.getFrom());
3587
3588                String formType = responseElement == null ? null : responseElement.getAttribute("type");
3589                if (responseElement != null &&
3590                    responseElement.getName().equals("x") &&
3591                    responseElement.getNamespace().equals("jabber:x:data") &&
3592                    formType != null && formType.equals("form")) {
3593
3594                    responseElement.setAttribute("type", "submit");
3595                    packet
3596                        .addChild("query", "http://jabber.org/protocol/muc#owner")
3597                        .addChild(responseElement);
3598                }
3599
3600                executing = true;
3601                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3602                    updateWithResponse(iq);
3603                }, 120L);
3604
3605                loading();
3606
3607                return false;
3608            }
3609        }
3610    }
3611
3612    public static class Thread {
3613        protected Message subject = null;
3614        protected Message first = null;
3615        protected Message last = null;
3616        protected final String threadId;
3617
3618        protected Thread(final String threadId) {
3619            this.threadId = threadId;
3620        }
3621
3622        public String getThreadId() {
3623            return threadId;
3624        }
3625
3626        public String getSubject() {
3627            if (subject == null) return null;
3628
3629            return subject.getSubject();
3630        }
3631
3632        public String getDisplay() {
3633            final String s = getSubject();
3634            if (s != null) return s;
3635
3636            if (first != null) {
3637                return first.getBody();
3638            }
3639
3640            return "";
3641        }
3642
3643        public long getLastTime() {
3644            if (last == null) return 0;
3645
3646            return last.getTimeSent();
3647        }
3648    }
3649}