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