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