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