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