Conversation.java

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