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