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