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