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