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