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 != null && note.el != null ? 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 boolean expectingRemoval = false;
2631 protected List<Field> reported = null;
2632 protected SparseArray<Item> items = new SparseArray<>();
2633 protected XmppConnectionService xmppConnectionService;
2634 protected ActionsAdapter actionsAdapter;
2635 protected GridLayoutManager layoutManager;
2636 protected WebView actionToWebview = null;
2637 protected int fillableFieldCount = 0;
2638 protected IqPacket pendingResponsePacket = null;
2639 protected boolean waitingForRefresh = false;
2640
2641 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2642 loading();
2643 mTitle = title;
2644 mNode = node;
2645 this.xmppConnectionService = xmppConnectionService;
2646 if (mPager != null) setupLayoutManager();
2647 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2648 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2649 @Override
2650 public void onChanged() {
2651 if (mBinding == null) return;
2652
2653 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2654 }
2655
2656 @Override
2657 public void onInvalidated() {}
2658 });
2659 }
2660
2661 public String getTitle() {
2662 return mTitle;
2663 }
2664
2665 public String getNode() {
2666 return mNode;
2667 }
2668
2669 public void updateWithResponse(final IqPacket iq) {
2670 if (getView() != null && getView().isAttachedToWindow()) {
2671 getView().post(() -> updateWithResponseUiThread(iq));
2672 } else {
2673 pendingResponsePacket = iq;
2674 }
2675 }
2676
2677 protected void updateWithResponseUiThread(final IqPacket iq) {
2678 Timer oldTimer = this.loadingTimer;
2679 this.loadingTimer = new Timer();
2680 oldTimer.cancel();
2681 this.executing = false;
2682 this.loading = false;
2683 this.loadingHasBeenLong = false;
2684 this.responseElement = null;
2685 this.fillableFieldCount = 0;
2686 this.reported = null;
2687 this.response = iq;
2688 this.items.clear();
2689 this.actionsAdapter.clear();
2690 layoutManager.setSpanCount(1);
2691
2692 boolean actionsCleared = false;
2693 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2694 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2695 if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2696 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2697 }
2698
2699 if (xmppConnectionService.isOnboarding() && mNode.equals("jabber:iq:register") && !"canceled".equals(command.getAttribute("status")) && xmppConnectionService.getPreferences().contains("onboarding_action")) {
2700 xmppConnectionService.getPreferences().edit().putBoolean("onboarding_continued", true).commit();
2701 }
2702
2703 Element actions = command.findChild("actions", "http://jabber.org/protocol/commands");
2704 if (actions != null) {
2705 for (Element action : actions.getChildren()) {
2706 if (!"http://jabber.org/protocol/commands".equals(action.getNamespace())) continue;
2707 if ("execute".equals(action.getName())) continue;
2708
2709 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2710 }
2711 }
2712
2713 for (Element el : command.getChildren()) {
2714 if ("x".equals(el.getName()) && "jabber:x:data".equals(el.getNamespace())) {
2715 Data form = Data.parse(el);
2716 String title = form.getTitle();
2717 if (title != null) {
2718 mTitle = title;
2719 ConversationPagerAdapter.this.notifyDataSetChanged();
2720 }
2721
2722 if ("result".equals(el.getAttribute("type")) || "form".equals(el.getAttribute("type"))) {
2723 this.responseElement = el;
2724 setupReported(el.findChild("reported", "jabber:x:data"));
2725 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2726 }
2727
2728 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2729 if (actionList != null) {
2730 actionsAdapter.clear();
2731
2732 for (Option action : actionList.getOptions()) {
2733 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2734 }
2735 }
2736
2737 String fillableFieldType = null;
2738 String fillableFieldValue = null;
2739 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2740 if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2741 fillableFieldType = field.getType();
2742 fillableFieldValue = field.getValue();
2743 fillableFieldCount++;
2744 }
2745 }
2746
2747 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2748 actionsCleared = true;
2749 actionsAdapter.clearExceptCancel();
2750 }
2751 break;
2752 }
2753 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2754 String url = el.findChildContent("url", "jabber:x:oob");
2755 if (url != null) {
2756 String scheme = Uri.parse(url).getScheme();
2757 if (scheme.equals("http") || scheme.equals("https")) {
2758 this.responseElement = el;
2759 break;
2760 }
2761 if (scheme.equals("xmpp")) {
2762 expectingRemoval = true;
2763 final Intent intent = new Intent(getView().getContext(), UriHandlerActivity.class);
2764 intent.setAction(Intent.ACTION_VIEW);
2765 intent.setData(Uri.parse(url));
2766 getView().getContext().startActivity(intent);
2767 break;
2768 }
2769 }
2770 }
2771 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2772 this.responseElement = el;
2773 break;
2774 }
2775 }
2776
2777 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2778 if ("jabber:iq:register".equals(mNode) && "canceled".equals(command.getAttribute("status"))) {
2779 if (xmppConnectionService.isOnboarding()) {
2780 if (xmppConnectionService.getPreferences().contains("onboarding_action")) {
2781 xmppConnectionService.deleteAccount(getAccount());
2782 } else {
2783 if (xmppConnectionService.getPreferences().getBoolean("onboarding_continued", false)) {
2784 removeSession(this);
2785 return;
2786 } else {
2787 xmppConnectionService.getPreferences().edit().putString("onboarding_action", "cancel").commit();
2788 xmppConnectionService.deleteAccount(getAccount());
2789 }
2790 }
2791 }
2792 xmppConnectionService.archiveConversation(Conversation.this);
2793 }
2794
2795 expectingRemoval = true;
2796 removeSession(this);
2797 return;
2798 }
2799
2800 if ("executing".equals(command.getAttribute("status")) && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2801 // No actions have been given, but we are not done?
2802 // This is probably a spec violation, but we should do *something*
2803 actionsAdapter.add(Pair.create("execute", "execute"));
2804 }
2805
2806 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2807 if ("completed".equals(command.getAttribute("status")) || "canceled".equals(command.getAttribute("status"))) {
2808 actionsAdapter.add(Pair.create("close", "close"));
2809 } else if (actionsAdapter.getPosition("cancel") < 0 && !xmppConnectionService.isOnboarding()) {
2810 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2811 }
2812 }
2813 }
2814
2815 if (actionsAdapter.isEmpty()) {
2816 actionsAdapter.add(Pair.create("close", "close"));
2817 }
2818
2819 actionsAdapter.sort((x, y) -> {
2820 if (x.first.equals("cancel")) return -1;
2821 if (y.first.equals("cancel")) return 1;
2822 if (x.first.equals("prev") && xmppConnectionService.isOnboarding()) return -1;
2823 if (y.first.equals("prev") && xmppConnectionService.isOnboarding()) return 1;
2824 return 0;
2825 });
2826
2827 Data dataForm = null;
2828 if (responseElement != null && responseElement.getName().equals("x") && responseElement.getNamespace().equals("jabber:x:data")) dataForm = Data.parse(responseElement);
2829 if (mNode.equals("jabber:iq:register") &&
2830 xmppConnectionService.getPreferences().contains("onboarding_action") &&
2831 dataForm != null && dataForm.getFieldByName("gateway-jid") != null) {
2832
2833
2834 dataForm.put("gateway-jid", xmppConnectionService.getPreferences().getString("onboarding_action", ""));
2835 execute();
2836 }
2837 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
2838 notifyDataSetChanged();
2839 }
2840
2841 protected void setupReported(Element el) {
2842 if (el == null) {
2843 reported = null;
2844 return;
2845 }
2846
2847 reported = new ArrayList<>();
2848 for (Element fieldEl : el.getChildren()) {
2849 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2850 reported.add(mkField(fieldEl));
2851 }
2852 }
2853
2854 @Override
2855 public int getItemCount() {
2856 if (loading) return 1;
2857 if (response == null) return 0;
2858 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2859 int i = 0;
2860 for (Element el : responseElement.getChildren()) {
2861 if (!el.getNamespace().equals("jabber:x:data")) continue;
2862 if (el.getName().equals("title")) continue;
2863 if (el.getName().equals("field")) {
2864 String type = el.getAttribute("type");
2865 if (type != null && type.equals("hidden")) continue;
2866 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2867 }
2868
2869 if (el.getName().equals("reported") || el.getName().equals("item")) {
2870 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2871 if (el.getName().equals("reported")) continue;
2872 i += 1;
2873 } else {
2874 if (reported != null) i += reported.size();
2875 }
2876 continue;
2877 }
2878
2879 i++;
2880 }
2881 return i;
2882 }
2883 return 1;
2884 }
2885
2886 public Item getItem(int position) {
2887 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2888 if (items.get(position) != null) return items.get(position);
2889 if (response == null) return null;
2890
2891 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2892 if (responseElement.getNamespace().equals("jabber:x:data")) {
2893 int i = 0;
2894 for (Element el : responseElement.getChildren()) {
2895 if (!el.getNamespace().equals("jabber:x:data")) continue;
2896 if (el.getName().equals("title")) continue;
2897 if (el.getName().equals("field")) {
2898 String type = el.getAttribute("type");
2899 if (type != null && type.equals("hidden")) continue;
2900 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2901 }
2902
2903 if (el.getName().equals("reported") || el.getName().equals("item")) {
2904 Cell cell = null;
2905
2906 if (reported != null) {
2907 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2908 if (el.getName().equals("reported")) continue;
2909 if (i == position) {
2910 items.put(position, new Item(el, TYPE_ITEM_CARD));
2911 return items.get(position);
2912 }
2913 } else {
2914 if (reported.size() > position - i) {
2915 Field reportedField = reported.get(position - i);
2916 Element itemField = null;
2917 if (el.getName().equals("item")) {
2918 for (Element subel : el.getChildren()) {
2919 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2920 itemField = subel;
2921 break;
2922 }
2923 }
2924 }
2925 cell = new Cell(reportedField, itemField);
2926 } else {
2927 i += reported.size();
2928 continue;
2929 }
2930 }
2931 }
2932
2933 if (cell != null) {
2934 items.put(position, cell);
2935 return cell;
2936 }
2937 }
2938
2939 if (i < position) {
2940 i++;
2941 continue;
2942 }
2943
2944 return mkItem(el, position);
2945 }
2946 }
2947 }
2948
2949 return mkItem(responseElement == null ? response : responseElement, position);
2950 }
2951
2952 @Override
2953 public int getItemViewType(int position) {
2954 return getItem(position).viewType;
2955 }
2956
2957 @Override
2958 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2959 switch(viewType) {
2960 case TYPE_ERROR: {
2961 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2962 return new ErrorViewHolder(binding);
2963 }
2964 case TYPE_NOTE: {
2965 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2966 return new NoteViewHolder(binding);
2967 }
2968 case TYPE_WEB: {
2969 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2970 return new WebViewHolder(binding);
2971 }
2972 case TYPE_RESULT_FIELD: {
2973 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2974 return new ResultFieldViewHolder(binding);
2975 }
2976 case TYPE_RESULT_CELL: {
2977 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2978 return new ResultCellViewHolder(binding);
2979 }
2980 case TYPE_ITEM_CARD: {
2981 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2982 return new ItemCardViewHolder(binding);
2983 }
2984 case TYPE_CHECKBOX_FIELD: {
2985 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2986 return new CheckboxFieldViewHolder(binding);
2987 }
2988 case TYPE_SEARCH_LIST_FIELD: {
2989 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2990 return new SearchListFieldViewHolder(binding);
2991 }
2992 case TYPE_RADIO_EDIT_FIELD: {
2993 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2994 return new RadioEditFieldViewHolder(binding);
2995 }
2996 case TYPE_SPINNER_FIELD: {
2997 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2998 return new SpinnerFieldViewHolder(binding);
2999 }
3000 case TYPE_BUTTON_GRID_FIELD: {
3001 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
3002 return new ButtonGridFieldViewHolder(binding);
3003 }
3004 case TYPE_TEXT_FIELD: {
3005 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
3006 return new TextFieldViewHolder(binding);
3007 }
3008 case TYPE_PROGRESSBAR: {
3009 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
3010 return new ProgressBarViewHolder(binding);
3011 }
3012 default:
3013 if (expectingRemoval) {
3014 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
3015 return new NoteViewHolder(binding);
3016 }
3017
3018 throw new IllegalArgumentException("Unknown viewType: " + viewType + " based on: " + response + ", " + responseElement + ", " + expectingRemoval);
3019 }
3020 }
3021
3022 @Override
3023 public void onBindViewHolder(ViewHolder viewHolder, int position) {
3024 viewHolder.bind(getItem(position));
3025 }
3026
3027 public View getView() {
3028 if (mBinding == null) return null;
3029 return mBinding.getRoot();
3030 }
3031
3032 public boolean validate() {
3033 int count = getItemCount();
3034 boolean isValid = true;
3035 for (int i = 0; i < count; i++) {
3036 boolean oneIsValid = getItem(i).validate();
3037 isValid = isValid && oneIsValid;
3038 }
3039 notifyDataSetChanged();
3040 return isValid;
3041 }
3042
3043 public boolean execute() {
3044 return execute("execute");
3045 }
3046
3047 public boolean execute(int actionPosition) {
3048 return execute(actionsAdapter.getItem(actionPosition).first);
3049 }
3050
3051 public synchronized boolean execute(String action) {
3052 if (!"cancel".equals(action) && executing) {
3053 loadingHasBeenLong = true;
3054 notifyDataSetChanged();
3055 return false;
3056 }
3057 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
3058
3059 if (response == null) return true;
3060 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
3061 if (command == null) return true;
3062 String status = command.getAttribute("status");
3063 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
3064
3065 if (actionToWebview != null && !action.equals("cancel") && Build.VERSION.SDK_INT >= 23) {
3066 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
3067 return false;
3068 }
3069
3070 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
3071 packet.setTo(response.getFrom());
3072 final Element c = packet.addChild("command", Namespace.COMMANDS);
3073 c.setAttribute("node", mNode);
3074 c.setAttribute("sessionid", command.getAttribute("sessionid"));
3075
3076 String formType = responseElement == null ? null : responseElement.getAttribute("type");
3077 if (!action.equals("cancel") &&
3078 !action.equals("prev") &&
3079 responseElement != null &&
3080 responseElement.getName().equals("x") &&
3081 responseElement.getNamespace().equals("jabber:x:data") &&
3082 formType != null && formType.equals("form")) {
3083
3084 Data form = Data.parse(responseElement);
3085 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
3086 if (actionList != null) {
3087 actionList.setValue(action);
3088 c.setAttribute("action", "execute");
3089 }
3090
3091 if (mNode.equals("jabber:iq:register") && xmppConnectionService.isOnboarding() && form.getFieldByName("gateway-jid") != null) {
3092 if (form.getValue("gateway-jid") == null) {
3093 xmppConnectionService.getPreferences().edit().remove("onboarding_action").commit();
3094 } else {
3095 xmppConnectionService.getPreferences().edit().putString("onboarding_action", form.getValue("gateway-jid")).commit();
3096 }
3097 }
3098
3099 responseElement.setAttribute("type", "submit");
3100 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
3101 if (rsm != null) {
3102 Element max = new Element("max", "http://jabber.org/protocol/rsm");
3103 max.setContent("1000");
3104 rsm.addChild(max);
3105 }
3106
3107 c.addChild(responseElement);
3108 }
3109
3110 if (c.getAttribute("action") == null) c.setAttribute("action", action);
3111
3112 executing = true;
3113 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
3114 updateWithResponse(iq);
3115 }, 120L);
3116
3117 loading();
3118 return false;
3119 }
3120
3121 public void refresh() {
3122 synchronized(this) {
3123 if (waitingForRefresh) notifyDataSetChanged();
3124 }
3125 }
3126
3127 protected void loading() {
3128 View v = getView();
3129 try {
3130 loadingTimer.schedule(new TimerTask() {
3131 @Override
3132 public void run() {
3133 View v2 = getView();
3134 loading = true;
3135
3136 loadingTimer.schedule(new TimerTask() {
3137 @Override
3138 public void run() {
3139 loadingHasBeenLong = true;
3140 if (v == null && v2 == null) return;
3141 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3142 }
3143 }, 3000);
3144
3145 if (v == null && v2 == null) return;
3146 (v == null ? v2 : v).post(() -> notifyDataSetChanged());
3147 }
3148 }, 500);
3149 } catch (final IllegalStateException e) { }
3150 }
3151
3152 protected GridLayoutManager setupLayoutManager() {
3153 int spanCount = 1;
3154
3155 Context ctx = mPager == null ? getView().getContext() : mPager.getContext();
3156 if (reported != null) {
3157 float screenWidth = ctx.getResources().getDisplayMetrics().widthPixels;
3158 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
3159 float tableHeaderWidth = reported.stream().reduce(
3160 0f,
3161 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------") + "\t", paint),
3162 (a, b) -> a + b
3163 );
3164
3165 spanCount = tableHeaderWidth > 0.59 * screenWidth ? 1 : this.reported.size();
3166 }
3167
3168 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
3169 items.clear();
3170 notifyDataSetChanged();
3171 }
3172
3173 layoutManager = new GridLayoutManager(ctx, spanCount);
3174 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
3175 @Override
3176 public int getSpanSize(int position) {
3177 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
3178 return 1;
3179 }
3180 });
3181 return layoutManager;
3182 }
3183
3184 protected void setBinding(CommandPageBinding b) {
3185 mBinding = b;
3186 // https://stackoverflow.com/a/32350474/8611
3187 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
3188 @Override
3189 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
3190 if(rv.getChildCount() > 0) {
3191 int[] location = new int[2];
3192 rv.getLocationOnScreen(location);
3193 View childView = rv.findChildViewUnder(e.getX(), e.getY());
3194 if (childView instanceof ViewGroup) {
3195 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
3196 }
3197 int action = e.getAction();
3198 switch (action) {
3199 case MotionEvent.ACTION_DOWN:
3200 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
3201 rv.requestDisallowInterceptTouchEvent(true);
3202 }
3203 case MotionEvent.ACTION_UP:
3204 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
3205 rv.requestDisallowInterceptTouchEvent(true);
3206 }
3207 }
3208 }
3209
3210 return false;
3211 }
3212
3213 @Override
3214 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
3215
3216 @Override
3217 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
3218 });
3219 mBinding.form.setLayoutManager(setupLayoutManager());
3220 mBinding.form.setAdapter(this);
3221 mBinding.actions.setAdapter(actionsAdapter);
3222 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
3223 if (execute(pos)) {
3224 removeSession(CommandSession.this);
3225 }
3226 });
3227
3228 actionsAdapter.notifyDataSetChanged();
3229
3230 if (pendingResponsePacket != null) {
3231 final IqPacket pending = pendingResponsePacket;
3232 pendingResponsePacket = null;
3233 updateWithResponseUiThread(pending);
3234 }
3235 }
3236
3237 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
3238 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
3239 setBinding(binding);
3240 return binding.getRoot();
3241 }
3242
3243 // https://stackoverflow.com/a/36037991/8611
3244 private View findViewAt(ViewGroup viewGroup, float x, float y) {
3245 for(int i = 0; i < viewGroup.getChildCount(); i++) {
3246 View child = viewGroup.getChildAt(i);
3247 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
3248 View foundView = findViewAt((ViewGroup) child, x, y);
3249 if (foundView != null && foundView.isShown()) {
3250 return foundView;
3251 }
3252 } else {
3253 int[] location = new int[2];
3254 child.getLocationOnScreen(location);
3255 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
3256 if (rect.contains((int)x, (int)y)) {
3257 return child;
3258 }
3259 }
3260 }
3261
3262 return null;
3263 }
3264 }
3265 }
3266}