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