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