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