1package eu.siacs.conversations.entities;
2
3import android.content.ContentValues;
4import android.database.Cursor;
5import android.database.DataSetObserver;
6import android.graphics.Rect;
7import android.net.Uri;
8import android.text.Editable;
9import android.text.InputType;
10import android.text.SpannableStringBuilder;
11import android.text.Spanned;
12import android.text.StaticLayout;
13import android.text.TextPaint;
14import android.text.TextUtils;
15import android.text.TextWatcher;
16import android.view.LayoutInflater;
17import android.view.MotionEvent;
18import android.view.Gravity;
19import android.view.View;
20import android.view.ViewGroup;
21import android.widget.ArrayAdapter;
22import android.widget.AdapterView;
23import android.widget.CompoundButton;
24import android.widget.ListView;
25import android.widget.TextView;
26import android.widget.Toast;
27import android.widget.Spinner;
28import android.webkit.JavascriptInterface;
29import android.webkit.WebMessage;
30import android.webkit.WebView;
31import android.webkit.WebViewClient;
32import android.webkit.WebChromeClient;
33import android.util.SparseArray;
34
35import androidx.annotation.NonNull;
36import androidx.annotation.Nullable;
37import androidx.core.content.ContextCompat;
38import androidx.databinding.DataBindingUtil;
39import androidx.databinding.ViewDataBinding;
40import androidx.viewpager.widget.PagerAdapter;
41import androidx.recyclerview.widget.RecyclerView;
42import androidx.recyclerview.widget.GridLayoutManager;
43import androidx.viewpager.widget.ViewPager;
44
45import com.google.android.material.tabs.TabLayout;
46import com.google.android.material.textfield.TextInputLayout;
47import com.google.common.base.Optional;
48import com.google.common.collect.ComparisonChain;
49import com.google.common.collect.Lists;
50
51import org.json.JSONArray;
52import org.json.JSONException;
53import org.json.JSONObject;
54
55import java.util.ArrayList;
56import java.util.Collections;
57import java.util.Iterator;
58import java.util.List;
59import java.util.ListIterator;
60import java.util.concurrent.atomic.AtomicBoolean;
61import java.util.stream.Collectors;
62import java.util.Timer;
63import java.util.TimerTask;
64
65import me.saket.bettermovementmethod.BetterLinkMovementMethod;
66
67import eu.siacs.conversations.Config;
68import eu.siacs.conversations.R;
69import eu.siacs.conversations.crypto.OmemoSetting;
70import eu.siacs.conversations.crypto.PgpDecryptionService;
71import eu.siacs.conversations.databinding.CommandPageBinding;
72import eu.siacs.conversations.databinding.CommandNoteBinding;
73import eu.siacs.conversations.databinding.CommandResultFieldBinding;
74import eu.siacs.conversations.databinding.CommandResultCellBinding;
75import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
76import eu.siacs.conversations.databinding.CommandProgressBarBinding;
77import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
78import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
79import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
80import eu.siacs.conversations.databinding.CommandTextFieldBinding;
81import eu.siacs.conversations.databinding.CommandWebviewBinding;
82import eu.siacs.conversations.persistance.DatabaseBackend;
83import eu.siacs.conversations.services.AvatarService;
84import eu.siacs.conversations.services.QuickConversationsService;
85import eu.siacs.conversations.services.XmppConnectionService;
86import eu.siacs.conversations.ui.text.FixedURLSpan;
87import eu.siacs.conversations.ui.util.ShareUtil;
88import eu.siacs.conversations.utils.JidHelper;
89import eu.siacs.conversations.utils.MessageUtils;
90import eu.siacs.conversations.utils.UIHelper;
91import eu.siacs.conversations.xml.Element;
92import eu.siacs.conversations.xml.Namespace;
93import eu.siacs.conversations.xmpp.Jid;
94import eu.siacs.conversations.xmpp.Option;
95import eu.siacs.conversations.xmpp.chatstate.ChatState;
96import eu.siacs.conversations.xmpp.mam.MamReference;
97import eu.siacs.conversations.xmpp.stanzas.IqPacket;
98
99import static eu.siacs.conversations.entities.Bookmark.printableValue;
100
101
102public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
103 public static final String TABLENAME = "conversations";
104
105 public static final int STATUS_AVAILABLE = 0;
106 public static final int STATUS_ARCHIVED = 1;
107
108 public static final String NAME = "name";
109 public static final String ACCOUNT = "accountUuid";
110 public static final String CONTACT = "contactUuid";
111 public static final String CONTACTJID = "contactJid";
112 public static final String STATUS = "status";
113 public static final String CREATED = "created";
114 public static final String MODE = "mode";
115 public static final String ATTRIBUTES = "attributes";
116
117 public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
118 public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
119 public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
120 public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
121 public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
122 static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
123 static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
124 static final String ATTRIBUTE_MODERATED = "moderated";
125 static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
126 private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
127 private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
128 private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
129 private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
130 private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
131 protected final ArrayList<Message> messages = new ArrayList<>();
132 public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
133 protected Account account = null;
134 private String draftMessage;
135 private final String name;
136 private final String contactUuid;
137 private final String accountUuid;
138 private Jid contactJid;
139 private int status;
140 private final long created;
141 private int mode;
142 private JSONObject attributes;
143 private Jid nextCounterpart;
144 private transient MucOptions mucOptions = null;
145 private boolean messagesLeftOnServer = true;
146 private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
147 private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
148 private String mFirstMamReference = null;
149 protected int mCurrentTab = -1;
150 protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
151 protected Element thread = null;
152
153 public Conversation(final String name, final Account account, final Jid contactJid,
154 final int mode) {
155 this(java.util.UUID.randomUUID().toString(), name, null, account
156 .getUuid(), contactJid, System.currentTimeMillis(),
157 STATUS_AVAILABLE, mode, "");
158 this.account = account;
159 }
160
161 public Conversation(final String uuid, final String name, final String contactUuid,
162 final String accountUuid, final Jid contactJid, final long created, final int status,
163 final int mode, final String attributes) {
164 this.uuid = uuid;
165 this.name = name;
166 this.contactUuid = contactUuid;
167 this.accountUuid = accountUuid;
168 this.contactJid = contactJid;
169 this.created = created;
170 this.status = status;
171 this.mode = mode;
172 try {
173 this.attributes = new JSONObject(attributes == null ? "" : attributes);
174 } catch (JSONException e) {
175 this.attributes = new JSONObject();
176 }
177 }
178
179 public static Conversation fromCursor(Cursor cursor) {
180 return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
181 cursor.getString(cursor.getColumnIndex(NAME)),
182 cursor.getString(cursor.getColumnIndex(CONTACT)),
183 cursor.getString(cursor.getColumnIndex(ACCOUNT)),
184 JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
185 cursor.getLong(cursor.getColumnIndex(CREATED)),
186 cursor.getInt(cursor.getColumnIndex(STATUS)),
187 cursor.getInt(cursor.getColumnIndex(MODE)),
188 cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
189 }
190
191 public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
192 for (int i = messages.size() - 1; i >= 0; --i) {
193 final Message message = messages.get(i);
194 if (message.getStatus() <= Message.STATUS_RECEIVED
195 && (message.markable || isPrivateAndNonAnonymousMuc)
196 && !message.isPrivateMessage()) {
197 return message;
198 }
199 }
200 return null;
201 }
202
203 private static boolean suitableForOmemoByDefault(final Conversation conversation) {
204 if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
205 return false;
206 }
207 if (conversation.getContact().isOwnServer()) {
208 return false;
209 }
210 final String contact = conversation.getJid().getDomain().toEscapedString();
211 final String account = conversation.getAccount().getServer();
212 if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
213 return false;
214 }
215 return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
216 }
217
218 public boolean hasMessagesLeftOnServer() {
219 return messagesLeftOnServer;
220 }
221
222 public void setHasMessagesLeftOnServer(boolean value) {
223 this.messagesLeftOnServer = value;
224 }
225
226 public Message getFirstUnreadMessage() {
227 Message first = null;
228 synchronized (this.messages) {
229 for (int i = messages.size() - 1; i >= 0; --i) {
230 if (messages.get(i).isRead()) {
231 return first;
232 } else {
233 first = messages.get(i);
234 }
235 }
236 }
237 return first;
238 }
239
240 public String findMostRecentRemoteDisplayableId() {
241 final boolean multi = mode == Conversation.MODE_MULTI;
242 synchronized (this.messages) {
243 for (final Message message : Lists.reverse(this.messages)) {
244 if (message.getStatus() == Message.STATUS_RECEIVED) {
245 final String serverMsgId = message.getServerMsgId();
246 if (serverMsgId != null && multi) {
247 return serverMsgId;
248 }
249 return message.getRemoteMsgId();
250 }
251 }
252 }
253 return null;
254 }
255
256 public int countFailedDeliveries() {
257 int count = 0;
258 synchronized (this.messages) {
259 for(final Message message : this.messages) {
260 if (message.getStatus() == Message.STATUS_SEND_FAILED) {
261 ++count;
262 }
263 }
264 }
265 return count;
266 }
267
268 public Message getLastEditableMessage() {
269 synchronized (this.messages) {
270 for (final Message message : Lists.reverse(this.messages)) {
271 if (message.isEditable()) {
272 if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
273 return null;
274 }
275 return message;
276 }
277 }
278 }
279 return null;
280 }
281
282
283 public Message findUnsentMessageWithUuid(String uuid) {
284 synchronized (this.messages) {
285 for (final Message message : this.messages) {
286 final int s = message.getStatus();
287 if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
288 return message;
289 }
290 }
291 }
292 return null;
293 }
294
295 public void findWaitingMessages(OnMessageFound onMessageFound) {
296 final ArrayList<Message> results = new ArrayList<>();
297 synchronized (this.messages) {
298 for (Message message : this.messages) {
299 if (message.getStatus() == Message.STATUS_WAITING) {
300 results.add(message);
301 }
302 }
303 }
304 for (Message result : results) {
305 onMessageFound.onMessageFound(result);
306 }
307 }
308
309 public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
310 final ArrayList<Message> results = new ArrayList<>();
311 synchronized (this.messages) {
312 for (final Message message : this.messages) {
313 if (message.isRead()) {
314 continue;
315 }
316 results.add(message);
317 }
318 }
319 for (final Message result : results) {
320 onMessageFound.onMessageFound(result);
321 }
322 }
323
324 public Message findMessageWithFileAndUuid(final String uuid) {
325 synchronized (this.messages) {
326 for (final Message message : this.messages) {
327 final Transferable transferable = message.getTransferable();
328 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
329 if (message.getUuid().equals(uuid)
330 && message.getEncryption() != Message.ENCRYPTION_PGP
331 && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
332 return message;
333 }
334 }
335 }
336 return null;
337 }
338
339 public Message findMessageWithUuid(final String uuid) {
340 synchronized (this.messages) {
341 for (final Message message : this.messages) {
342 if (message.getUuid().equals(uuid)) {
343 return message;
344 }
345 }
346 }
347 return null;
348 }
349
350 public boolean markAsDeleted(final List<String> uuids) {
351 boolean deleted = false;
352 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
353 synchronized (this.messages) {
354 for (Message message : this.messages) {
355 if (uuids.contains(message.getUuid())) {
356 message.setDeleted(true);
357 deleted = true;
358 if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
359 pgpDecryptionService.discard(message);
360 }
361 }
362 }
363 }
364 return deleted;
365 }
366
367 public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
368 boolean changed = false;
369 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
370 synchronized (this.messages) {
371 for (Message message : this.messages) {
372 for (final DatabaseBackend.FilePathInfo file : files)
373 if (file.uuid.toString().equals(message.getUuid())) {
374 message.setDeleted(file.deleted);
375 changed = true;
376 if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
377 pgpDecryptionService.discard(message);
378 }
379 }
380 }
381 }
382 return changed;
383 }
384
385 public void clearMessages() {
386 synchronized (this.messages) {
387 this.messages.clear();
388 }
389 }
390
391 public boolean setIncomingChatState(ChatState state) {
392 if (this.mIncomingChatState == state) {
393 return false;
394 }
395 this.mIncomingChatState = state;
396 return true;
397 }
398
399 public ChatState getIncomingChatState() {
400 return this.mIncomingChatState;
401 }
402
403 public boolean setOutgoingChatState(ChatState state) {
404 if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
405 if (this.mOutgoingChatState != state) {
406 this.mOutgoingChatState = state;
407 return true;
408 }
409 }
410 return false;
411 }
412
413 public ChatState getOutgoingChatState() {
414 return this.mOutgoingChatState;
415 }
416
417 public void trim() {
418 synchronized (this.messages) {
419 final int size = messages.size();
420 final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
421 if (size > maxsize) {
422 List<Message> discards = this.messages.subList(0, size - maxsize);
423 final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
424 if (pgpDecryptionService != null) {
425 pgpDecryptionService.discard(discards);
426 }
427 discards.clear();
428 untieMessages();
429 }
430 }
431 }
432
433 public void findUnsentTextMessages(OnMessageFound onMessageFound) {
434 final ArrayList<Message> results = new ArrayList<>();
435 synchronized (this.messages) {
436 for (Message message : this.messages) {
437 if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
438 results.add(message);
439 }
440 }
441 }
442 for (Message result : results) {
443 onMessageFound.onMessageFound(result);
444 }
445 }
446
447 public Message findSentMessageWithUuidOrRemoteId(String id) {
448 synchronized (this.messages) {
449 for (Message message : this.messages) {
450 if (id.equals(message.getUuid())
451 || (message.getStatus() >= Message.STATUS_SEND
452 && id.equals(message.getRemoteMsgId()))) {
453 return message;
454 }
455 }
456 }
457 return null;
458 }
459
460 public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
461 synchronized (this.messages) {
462 for (int i = this.messages.size() - 1; i >= 0; --i) {
463 final Message message = messages.get(i);
464 final Jid mcp = message.getCounterpart();
465 if (mcp == null) {
466 continue;
467 }
468 if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
469 && (carbon == message.isCarbon() || received)) {
470 final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
471 if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
472 return message;
473 } else {
474 return null;
475 }
476 }
477 }
478 }
479 return null;
480 }
481
482 public Message findSentMessageWithUuid(String id) {
483 synchronized (this.messages) {
484 for (Message message : this.messages) {
485 if (id.equals(message.getUuid())) {
486 return message;
487 }
488 }
489 }
490 return null;
491 }
492
493 public Message findMessageWithRemoteId(String id, Jid counterpart) {
494 synchronized (this.messages) {
495 for (Message message : this.messages) {
496 if (counterpart.equals(message.getCounterpart())
497 && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
498 return message;
499 }
500 }
501 }
502 return null;
503 }
504
505 public Message findMessageWithServerMsgId(String id) {
506 synchronized (this.messages) {
507 for (Message message : this.messages) {
508 if (id != null && id.equals(message.getServerMsgId())) {
509 return message;
510 }
511 }
512 }
513 return null;
514 }
515
516 public boolean hasMessageWithCounterpart(Jid counterpart) {
517 synchronized (this.messages) {
518 for (Message message : this.messages) {
519 if (counterpart.equals(message.getCounterpart())) {
520 return true;
521 }
522 }
523 }
524 return false;
525 }
526
527 public void populateWithMessages(final List<Message> messages) {
528 synchronized (this.messages) {
529 messages.clear();
530 messages.addAll(this.messages);
531 }
532 for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
533 if (iterator.next().wasMergedIntoPrevious()) {
534 iterator.remove();
535 }
536 }
537 }
538
539 @Override
540 public boolean isBlocked() {
541 return getContact().isBlocked();
542 }
543
544 @Override
545 public boolean isDomainBlocked() {
546 return getContact().isDomainBlocked();
547 }
548
549 @Override
550 public Jid getBlockedJid() {
551 return getContact().getBlockedJid();
552 }
553
554 public int countMessages() {
555 synchronized (this.messages) {
556 return this.messages.size();
557 }
558 }
559
560 public String getFirstMamReference() {
561 return this.mFirstMamReference;
562 }
563
564 public void setFirstMamReference(String reference) {
565 this.mFirstMamReference = reference;
566 }
567
568 public void setLastClearHistory(long time, String reference) {
569 if (reference != null) {
570 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
571 } else {
572 setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
573 }
574 }
575
576 public MamReference getLastClearHistory() {
577 return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
578 }
579
580 public List<Jid> getAcceptedCryptoTargets() {
581 if (mode == MODE_SINGLE) {
582 return Collections.singletonList(getJid().asBareJid());
583 } else {
584 return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
585 }
586 }
587
588 public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
589 setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
590 }
591
592 public boolean setCorrectingMessage(Message correctingMessage) {
593 setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
594 return correctingMessage == null && draftMessage != null;
595 }
596
597 public Message getCorrectingMessage() {
598 final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
599 return uuid == null ? null : findSentMessageWithUuid(uuid);
600 }
601
602 public boolean withSelf() {
603 return getContact().isSelf();
604 }
605
606 @Override
607 public int compareTo(@NonNull Conversation another) {
608 return ComparisonChain.start()
609 .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
610 .compare(another.getSortableTime(), getSortableTime())
611 .result();
612 }
613
614 private long getSortableTime() {
615 Draft draft = getDraft();
616 long messageTime = getLatestMessage().getTimeReceived();
617 if (draft == null) {
618 return messageTime;
619 } else {
620 return Math.max(messageTime, draft.getTimestamp());
621 }
622 }
623
624 public String getDraftMessage() {
625 return draftMessage;
626 }
627
628 public void setDraftMessage(String draftMessage) {
629 this.draftMessage = draftMessage;
630 }
631
632 public Element getThread() {
633 return this.thread;
634 }
635
636 public void setThread(Element thread) {
637 this.thread = thread;
638 }
639
640 public boolean isRead() {
641 synchronized (this.messages) {
642 for(final Message message : Lists.reverse(this.messages)) {
643 if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
644 continue;
645 }
646 return message.isRead();
647 }
648 return true;
649 }
650 }
651
652 public List<Message> markRead(String upToUuid) {
653 final List<Message> unread = new ArrayList<>();
654 synchronized (this.messages) {
655 for (Message message : this.messages) {
656 if (!message.isRead()) {
657 message.markRead();
658 unread.add(message);
659 }
660 if (message.getUuid().equals(upToUuid)) {
661 return unread;
662 }
663 }
664 }
665 return unread;
666 }
667
668 public Message getLatestMessage() {
669 synchronized (this.messages) {
670 if (this.messages.size() == 0) {
671 Message message = new Message(this, "", Message.ENCRYPTION_NONE);
672 message.setType(Message.TYPE_STATUS);
673 message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
674 return message;
675 } else {
676 return this.messages.get(this.messages.size() - 1);
677 }
678 }
679 }
680
681 public @NonNull
682 CharSequence getName() {
683 if (getMode() == MODE_MULTI) {
684 final String roomName = getMucOptions().getName();
685 final String subject = getMucOptions().getSubject();
686 final Bookmark bookmark = getBookmark();
687 final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
688 if (printableValue(roomName)) {
689 return roomName;
690 } else if (printableValue(subject)) {
691 return subject;
692 } else if (printableValue(bookmarkName, false)) {
693 return bookmarkName;
694 } else {
695 final String generatedName = getMucOptions().createNameFromParticipants();
696 if (printableValue(generatedName)) {
697 return generatedName;
698 } else {
699 return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
700 }
701 }
702 } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
703 return contactJid;
704 } else {
705 return this.getContact().getDisplayName();
706 }
707 }
708
709 public String getAccountUuid() {
710 return this.accountUuid;
711 }
712
713 public Account getAccount() {
714 return this.account;
715 }
716
717 public void setAccount(final Account account) {
718 this.account = account;
719 }
720
721 public Contact getContact() {
722 return this.account.getRoster().getContact(this.contactJid);
723 }
724
725 @Override
726 public Jid getJid() {
727 return this.contactJid;
728 }
729
730 public int getStatus() {
731 return this.status;
732 }
733
734 public void setStatus(int status) {
735 this.status = status;
736 }
737
738 public long getCreated() {
739 return this.created;
740 }
741
742 public ContentValues getContentValues() {
743 ContentValues values = new ContentValues();
744 values.put(UUID, uuid);
745 values.put(NAME, name);
746 values.put(CONTACT, contactUuid);
747 values.put(ACCOUNT, accountUuid);
748 values.put(CONTACTJID, contactJid.toString());
749 values.put(CREATED, created);
750 values.put(STATUS, status);
751 values.put(MODE, mode);
752 synchronized (this.attributes) {
753 values.put(ATTRIBUTES, attributes.toString());
754 }
755 return values;
756 }
757
758 public int getMode() {
759 return this.mode;
760 }
761
762 public void setMode(int mode) {
763 this.mode = mode;
764 }
765
766 /**
767 * short for is Private and Non-anonymous
768 */
769 public boolean isSingleOrPrivateAndNonAnonymous() {
770 return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
771 }
772
773 public boolean isPrivateAndNonAnonymous() {
774 return getMucOptions().isPrivateAndNonAnonymous();
775 }
776
777 public synchronized MucOptions getMucOptions() {
778 if (this.mucOptions == null) {
779 this.mucOptions = new MucOptions(this);
780 }
781 return this.mucOptions;
782 }
783
784 public void resetMucOptions() {
785 this.mucOptions = null;
786 }
787
788 public void setContactJid(final Jid jid) {
789 this.contactJid = jid;
790 }
791
792 public Jid getNextCounterpart() {
793 return this.nextCounterpart;
794 }
795
796 public void setNextCounterpart(Jid jid) {
797 this.nextCounterpart = jid;
798 }
799
800 public int getNextEncryption() {
801 if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
802 return Message.ENCRYPTION_NONE;
803 }
804 if (OmemoSetting.isAlways()) {
805 return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
806 }
807 final int defaultEncryption;
808 if (suitableForOmemoByDefault(this)) {
809 defaultEncryption = OmemoSetting.getEncryption();
810 } else {
811 defaultEncryption = Message.ENCRYPTION_NONE;
812 }
813 int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
814 if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
815 return defaultEncryption;
816 } else {
817 return encryption;
818 }
819 }
820
821 public boolean setNextEncryption(int encryption) {
822 return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
823 }
824
825 public String getNextMessage() {
826 final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
827 return nextMessage == null ? "" : nextMessage;
828 }
829
830 public @Nullable
831 Draft getDraft() {
832 long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
833 if (timestamp > getLatestMessage().getTimeSent()) {
834 String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
835 if (!TextUtils.isEmpty(message) && timestamp != 0) {
836 return new Draft(message, timestamp);
837 }
838 }
839 return null;
840 }
841
842 public boolean setNextMessage(final String input) {
843 final String message = input == null || input.trim().isEmpty() ? null : input;
844 boolean changed = !getNextMessage().equals(message);
845 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
846 if (changed) {
847 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
848 }
849 return changed;
850 }
851
852 public Bookmark getBookmark() {
853 return this.account.getBookmark(this.contactJid);
854 }
855
856 public Message findDuplicateMessage(Message message) {
857 synchronized (this.messages) {
858 for (int i = this.messages.size() - 1; i >= 0; --i) {
859 if (this.messages.get(i).similar(message)) {
860 return this.messages.get(i);
861 }
862 }
863 }
864 return null;
865 }
866
867 public boolean hasDuplicateMessage(Message message) {
868 return findDuplicateMessage(message) != null;
869 }
870
871 public Message findSentMessageWithBody(String body) {
872 synchronized (this.messages) {
873 for (int i = this.messages.size() - 1; i >= 0; --i) {
874 Message message = this.messages.get(i);
875 if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
876 String otherBody;
877 if (message.hasFileOnRemoteHost()) {
878 otherBody = message.getFileParams().url;
879 } else {
880 otherBody = message.body;
881 }
882 if (otherBody != null && otherBody.equals(body)) {
883 return message;
884 }
885 }
886 }
887 return null;
888 }
889 }
890
891 public Message findRtpSession(final String sessionId, final int s) {
892 synchronized (this.messages) {
893 for (int i = this.messages.size() - 1; i >= 0; --i) {
894 final Message message = this.messages.get(i);
895 if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
896 return message;
897 }
898 }
899 }
900 return null;
901 }
902
903 public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
904 if (serverMsgId == null || remoteMsgId == null) {
905 return false;
906 }
907 synchronized (this.messages) {
908 for (Message message : this.messages) {
909 if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
910 return true;
911 }
912 }
913 }
914 return false;
915 }
916
917 public MamReference getLastMessageTransmitted() {
918 final MamReference lastClear = getLastClearHistory();
919 MamReference lastReceived = new MamReference(0);
920 synchronized (this.messages) {
921 for (int i = this.messages.size() - 1; i >= 0; --i) {
922 final Message message = this.messages.get(i);
923 if (message.isPrivateMessage()) {
924 continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
925 }
926 if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
927 lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
928 break;
929 }
930 }
931 }
932 return MamReference.max(lastClear, lastReceived);
933 }
934
935 public void setMutedTill(long value) {
936 this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
937 }
938
939 public boolean isMuted() {
940 return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
941 }
942
943 public boolean alwaysNotify() {
944 return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
945 }
946
947 public boolean setAttribute(String key, boolean value) {
948 return setAttribute(key, String.valueOf(value));
949 }
950
951 private boolean setAttribute(String key, long value) {
952 return setAttribute(key, Long.toString(value));
953 }
954
955 private boolean setAttribute(String key, int value) {
956 return setAttribute(key, String.valueOf(value));
957 }
958
959 public boolean setAttribute(String key, String value) {
960 synchronized (this.attributes) {
961 try {
962 if (value == null) {
963 if (this.attributes.has(key)) {
964 this.attributes.remove(key);
965 return true;
966 } else {
967 return false;
968 }
969 } else {
970 final String prev = this.attributes.optString(key, null);
971 this.attributes.put(key, value);
972 return !value.equals(prev);
973 }
974 } catch (JSONException e) {
975 throw new AssertionError(e);
976 }
977 }
978 }
979
980 public boolean setAttribute(String key, List<Jid> jids) {
981 JSONArray array = new JSONArray();
982 for (Jid jid : jids) {
983 array.put(jid.asBareJid().toString());
984 }
985 synchronized (this.attributes) {
986 try {
987 this.attributes.put(key, array);
988 return true;
989 } catch (JSONException e) {
990 return false;
991 }
992 }
993 }
994
995 public String getAttribute(String key) {
996 synchronized (this.attributes) {
997 return this.attributes.optString(key, null);
998 }
999 }
1000
1001 private List<Jid> getJidListAttribute(String key) {
1002 ArrayList<Jid> list = new ArrayList<>();
1003 synchronized (this.attributes) {
1004 try {
1005 JSONArray array = this.attributes.getJSONArray(key);
1006 for (int i = 0; i < array.length(); ++i) {
1007 try {
1008 list.add(Jid.of(array.getString(i)));
1009 } catch (IllegalArgumentException e) {
1010 //ignored
1011 }
1012 }
1013 } catch (JSONException e) {
1014 //ignored
1015 }
1016 }
1017 return list;
1018 }
1019
1020 private int getIntAttribute(String key, int defaultValue) {
1021 String value = this.getAttribute(key);
1022 if (value == null) {
1023 return defaultValue;
1024 } else {
1025 try {
1026 return Integer.parseInt(value);
1027 } catch (NumberFormatException e) {
1028 return defaultValue;
1029 }
1030 }
1031 }
1032
1033 public long getLongAttribute(String key, long defaultValue) {
1034 String value = this.getAttribute(key);
1035 if (value == null) {
1036 return defaultValue;
1037 } else {
1038 try {
1039 return Long.parseLong(value);
1040 } catch (NumberFormatException e) {
1041 return defaultValue;
1042 }
1043 }
1044 }
1045
1046 public boolean getBooleanAttribute(String key, boolean defaultValue) {
1047 String value = this.getAttribute(key);
1048 if (value == null) {
1049 return defaultValue;
1050 } else {
1051 return Boolean.parseBoolean(value);
1052 }
1053 }
1054
1055 public void add(Message message) {
1056 synchronized (this.messages) {
1057 this.messages.add(message);
1058 }
1059 }
1060
1061 public void prepend(int offset, Message message) {
1062 synchronized (this.messages) {
1063 this.messages.add(Math.min(offset, this.messages.size()), message);
1064 }
1065 }
1066
1067 public void addAll(int index, List<Message> messages) {
1068 synchronized (this.messages) {
1069 this.messages.addAll(index, messages);
1070 }
1071 account.getPgpDecryptionService().decrypt(messages);
1072 }
1073
1074 public void expireOldMessages(long timestamp) {
1075 synchronized (this.messages) {
1076 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1077 if (iterator.next().getTimeSent() < timestamp) {
1078 iterator.remove();
1079 }
1080 }
1081 untieMessages();
1082 }
1083 }
1084
1085 public void sort() {
1086 synchronized (this.messages) {
1087 Collections.sort(this.messages, (left, right) -> {
1088 if (left.getTimeSent() < right.getTimeSent()) {
1089 return -1;
1090 } else if (left.getTimeSent() > right.getTimeSent()) {
1091 return 1;
1092 } else {
1093 return 0;
1094 }
1095 });
1096 untieMessages();
1097 }
1098 }
1099
1100 private void untieMessages() {
1101 for (Message message : this.messages) {
1102 message.untie();
1103 }
1104 }
1105
1106 public int unreadCount() {
1107 synchronized (this.messages) {
1108 int count = 0;
1109 for(final Message message : Lists.reverse(this.messages)) {
1110 if (message.isRead()) {
1111 if (message.getType() == Message.TYPE_RTP_SESSION) {
1112 continue;
1113 }
1114 return count;
1115 }
1116 ++count;
1117 }
1118 return count;
1119 }
1120 }
1121
1122 public int receivedMessagesCount() {
1123 int count = 0;
1124 synchronized (this.messages) {
1125 for (Message message : messages) {
1126 if (message.getStatus() == Message.STATUS_RECEIVED) {
1127 ++count;
1128 }
1129 }
1130 }
1131 return count;
1132 }
1133
1134 public int sentMessagesCount() {
1135 int count = 0;
1136 synchronized (this.messages) {
1137 for (Message message : messages) {
1138 if (message.getStatus() != Message.STATUS_RECEIVED) {
1139 ++count;
1140 }
1141 }
1142 }
1143 return count;
1144 }
1145
1146 public boolean canInferPresence() {
1147 final Contact contact = getContact();
1148 if (contact != null && contact.canInferPresence()) return true;
1149 return sentMessagesCount() > 0;
1150 }
1151
1152 public boolean isWithStranger() {
1153 final Contact contact = getContact();
1154 return mode == MODE_SINGLE
1155 && !contact.isOwnServer()
1156 && !contact.showInContactList()
1157 && !contact.isSelf()
1158 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1159 && sentMessagesCount() == 0;
1160 }
1161
1162 public int getReceivedMessagesCountSinceUuid(String uuid) {
1163 if (uuid == null) {
1164 return 0;
1165 }
1166 int count = 0;
1167 synchronized (this.messages) {
1168 for (int i = messages.size() - 1; i >= 0; i--) {
1169 final Message message = messages.get(i);
1170 if (uuid.equals(message.getUuid())) {
1171 return count;
1172 }
1173 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1174 ++count;
1175 }
1176 }
1177 }
1178 return 0;
1179 }
1180
1181 @Override
1182 public int getAvatarBackgroundColor() {
1183 return UIHelper.getColorForName(getName().toString());
1184 }
1185
1186 @Override
1187 public String getAvatarName() {
1188 return getName().toString();
1189 }
1190
1191 public void setCurrentTab(int tab) {
1192 mCurrentTab = tab;
1193 }
1194
1195 public int getCurrentTab() {
1196 if (mCurrentTab >= 0) return mCurrentTab;
1197
1198 if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1199 return 0;
1200 }
1201
1202 return 1;
1203 }
1204
1205 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1206 pagerAdapter.startCommand(command, xmppConnectionService);
1207 }
1208
1209 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1210 pagerAdapter.setupViewPager(pager, tabs);
1211 }
1212
1213 public void showViewPager() {
1214 pagerAdapter.show();
1215 }
1216
1217 public void hideViewPager() {
1218 pagerAdapter.hide();
1219 }
1220
1221 public interface OnMessageFound {
1222 void onMessageFound(final Message message);
1223 }
1224
1225 public static class Draft {
1226 private final String message;
1227 private final long timestamp;
1228
1229 private Draft(String message, long timestamp) {
1230 this.message = message;
1231 this.timestamp = timestamp;
1232 }
1233
1234 public long getTimestamp() {
1235 return timestamp;
1236 }
1237
1238 public String getMessage() {
1239 return message;
1240 }
1241 }
1242
1243 public class ConversationPagerAdapter extends PagerAdapter {
1244 protected ViewPager mPager = null;
1245 protected TabLayout mTabs = null;
1246 ArrayList<CommandSession> sessions = null;
1247 protected View page1 = null;
1248 protected View page2 = null;
1249
1250 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1251 mPager = pager;
1252 mTabs = tabs;
1253
1254 if (mPager == null) return;
1255 if (sessions != null) show();
1256
1257 page1 = pager.getChildAt(0) == null ? page1 : pager.getChildAt(0);
1258 page2 = pager.getChildAt(1) == null ? page2 : pager.getChildAt(1);
1259 pager.setAdapter(this);
1260 tabs.setupWithViewPager(mPager);
1261 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1262
1263 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1264 public void onPageScrollStateChanged(int state) { }
1265 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1266
1267 public void onPageSelected(int position) {
1268 setCurrentTab(position);
1269 }
1270 });
1271 }
1272
1273 public void show() {
1274 if (sessions == null) {
1275 sessions = new ArrayList<>();
1276 notifyDataSetChanged();
1277 }
1278 if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1279 }
1280
1281 public void hide() {
1282 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1283 if (mPager != null) mPager.setCurrentItem(0);
1284 if (mTabs != null) mTabs.setVisibility(View.GONE);
1285 sessions = null;
1286 notifyDataSetChanged();
1287 }
1288
1289 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1290 show();
1291 CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1292
1293 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1294 packet.setTo(command.getAttributeAsJid("jid"));
1295 final Element c = packet.addChild("command", Namespace.COMMANDS);
1296 c.setAttribute("node", command.getAttribute("node"));
1297 c.setAttribute("action", "execute");
1298 View v = mPager;
1299 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1300 v.post(() -> {
1301 session.updateWithResponse(iq);
1302 });
1303 });
1304
1305 sessions.add(session);
1306 notifyDataSetChanged();
1307 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1308 }
1309
1310 public void removeSession(CommandSession session) {
1311 sessions.remove(session);
1312 notifyDataSetChanged();
1313 }
1314
1315 @NonNull
1316 @Override
1317 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1318 if (position == 0) {
1319 if (page1.getParent() == null) container.addView(page1);
1320 return page1;
1321 }
1322 if (position == 1) {
1323 if (page2.getParent() == null) container.addView(page2);
1324 return page2;
1325 }
1326
1327 CommandSession session = sessions.get(position-2);
1328 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1329 container.addView(binding.getRoot());
1330 session.setBinding(binding);
1331 return session;
1332 }
1333
1334 @Override
1335 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1336 if (position < 2) return;
1337
1338 container.removeView(((CommandSession) o).getView());
1339 }
1340
1341 @Override
1342 public int getItemPosition(Object o) {
1343 if (mPager != null) {
1344 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1345 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1346 }
1347
1348 int pos = sessions == null ? -1 : sessions.indexOf(o);
1349 if (pos < 0) return PagerAdapter.POSITION_NONE;
1350 return pos + 2;
1351 }
1352
1353 @Override
1354 public int getCount() {
1355 if (sessions == null) return 1;
1356
1357 int count = 2 + sessions.size();
1358 if (mTabs == null) return count;
1359
1360 if (count > 2) {
1361 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1362 } else {
1363 mTabs.setTabMode(TabLayout.MODE_FIXED);
1364 }
1365 return count;
1366 }
1367
1368 @Override
1369 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1370 if (view == o) return true;
1371
1372 if (o instanceof CommandSession) {
1373 return ((CommandSession) o).getView() == view;
1374 }
1375
1376 return false;
1377 }
1378
1379 @Nullable
1380 @Override
1381 public CharSequence getPageTitle(int position) {
1382 switch (position) {
1383 case 0:
1384 return "Conversation";
1385 case 1:
1386 return "Commands";
1387 default:
1388 CommandSession session = sessions.get(position-2);
1389 if (session == null) return super.getPageTitle(position);
1390 return session.getTitle();
1391 }
1392 }
1393
1394 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1395 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1396 protected T binding;
1397
1398 public ViewHolder(T binding) {
1399 super(binding.getRoot());
1400 this.binding = binding;
1401 }
1402
1403 abstract public void bind(Item el);
1404
1405 protected void setTextOrHide(TextView v, Optional<String> s) {
1406 if (s == null || !s.isPresent()) {
1407 v.setVisibility(View.GONE);
1408 } else {
1409 v.setVisibility(View.VISIBLE);
1410 v.setText(s.get());
1411 }
1412 }
1413
1414 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1415 int flags = 0;
1416 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1417 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1418
1419 String type = field.getAttribute("type");
1420 if (type != null) {
1421 if (type.equals("text-multi") || type.equals("jid-multi")) {
1422 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1423 }
1424
1425 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1426
1427 if (type.equals("jid-single") || type.equals("jid-multi")) {
1428 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1429 }
1430
1431 if (type.equals("text-private")) {
1432 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1433 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1434 }
1435 }
1436
1437 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1438 if (validate == null) return;
1439 String datatype = validate.getAttribute("datatype");
1440 if (datatype == null) return;
1441
1442 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1443 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1444 }
1445
1446 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1447 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1448 }
1449
1450 if (datatype.equals("xs:date")) {
1451 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1452 }
1453
1454 if (datatype.equals("xs:dateTime")) {
1455 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1456 }
1457
1458 if (datatype.equals("xs:time")) {
1459 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1460 }
1461
1462 if (datatype.equals("xs:anyURI")) {
1463 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1464 }
1465
1466 if (datatype.equals("html:tel")) {
1467 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1468 }
1469
1470 if (datatype.equals("html:email")) {
1471 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1472 }
1473 }
1474 }
1475
1476 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1477 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1478
1479 @Override
1480 public void bind(Item iq) {
1481 binding.errorIcon.setVisibility(View.VISIBLE);
1482
1483 Element error = iq.el.findChild("error");
1484 if (error == null) return;
1485 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1486 if (text == null || text.equals("")) {
1487 text = error.getChildren().get(0).getName();
1488 }
1489 binding.message.setText(text);
1490 }
1491 }
1492
1493 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1494 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1495
1496 @Override
1497 public void bind(Item note) {
1498 binding.message.setText(note.el.getContent());
1499
1500 String type = note.el.getAttribute("type");
1501 if (type != null && type.equals("error")) {
1502 binding.errorIcon.setVisibility(View.VISIBLE);
1503 }
1504 }
1505 }
1506
1507 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1508 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1509
1510 @Override
1511 public void bind(Item item) {
1512 Field field = (Field) item;
1513 setTextOrHide(binding.label, field.getLabel());
1514 setTextOrHide(binding.desc, field.getDesc());
1515
1516 ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1517 for (Element el : field.el.getChildren()) {
1518 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1519 values.add(el.getContent());
1520 }
1521 }
1522 binding.values.setAdapter(values);
1523
1524 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1525 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1526 new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1527 });
1528 }
1529
1530 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1531 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1532 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1533 }
1534 return true;
1535 });
1536 }
1537 }
1538
1539 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1540 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1541
1542 @Override
1543 public void bind(Item item) {
1544 Cell cell = (Cell) item;
1545
1546 if (cell.el == null) {
1547 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1548 setTextOrHide(binding.text, cell.reported.getLabel());
1549 } else {
1550 SpannableStringBuilder text = new SpannableStringBuilder(cell.el.findChildContent("value", "jabber:x:data"));
1551 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1552 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1553 }
1554
1555 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1556 binding.text.setText(text);
1557
1558 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1559 method.setOnLinkLongClickListener((tv, url) -> {
1560 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1561 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1562 return true;
1563 });
1564 binding.text.setMovementMethod(method);
1565 }
1566 }
1567 }
1568
1569 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1570 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1571 super(binding);
1572 binding.row.setOnClickListener((v) -> {
1573 binding.checkbox.toggle();
1574 });
1575 binding.checkbox.setOnCheckedChangeListener(this);
1576 }
1577 protected Element mValue = null;
1578
1579 @Override
1580 public void bind(Item item) {
1581 Field field = (Field) item;
1582 binding.label.setText(field.getLabel().or(""));
1583 setTextOrHide(binding.desc, field.getDesc());
1584 mValue = field.getValue();
1585 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1586 }
1587
1588 @Override
1589 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1590 if (mValue == null) return;
1591
1592 mValue.setContent(isChecked ? "true" : "false");
1593 }
1594 }
1595
1596 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1597 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1598 super(binding);
1599 binding.search.addTextChangedListener(this);
1600 }
1601 protected Element mValue = null;
1602 List<Option> options = new ArrayList<>();
1603 protected ArrayAdapter<Option> adapter;
1604 protected boolean open;
1605
1606 @Override
1607 public void bind(Item item) {
1608 Field field = (Field) item;
1609 setTextOrHide(binding.label, field.getLabel());
1610 setTextOrHide(binding.desc, field.getDesc());
1611
1612 if (field.error != null) {
1613 binding.desc.setVisibility(View.VISIBLE);
1614 binding.desc.setText(field.error);
1615 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1616 } else {
1617 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1618 }
1619
1620 mValue = field.getValue();
1621
1622 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1623 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1624 setupInputType(field.el, binding.search, null);
1625
1626 options = field.getOptions();
1627 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1628 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1629 if (open) binding.search.setText(mValue.getContent());
1630 });
1631 search("");
1632 }
1633
1634 @Override
1635 public void afterTextChanged(Editable s) {
1636 if (open) mValue.setContent(s.toString());
1637 search(s.toString());
1638 }
1639
1640 @Override
1641 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1642
1643 @Override
1644 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1645
1646 protected void search(String s) {
1647 List<Option> filteredOptions;
1648 final String q = s.replaceAll("\\W", "").toLowerCase();
1649 if (q == null || q.equals("")) {
1650 filteredOptions = options;
1651 } else {
1652 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1653 }
1654 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1655 binding.list.setAdapter(adapter);
1656
1657 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1658 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1659 }
1660 }
1661
1662 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1663 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1664 super(binding);
1665 binding.open.addTextChangedListener(this);
1666 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1667 @Override
1668 public View getView(int position, View convertView, ViewGroup parent) {
1669 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1670 v.setId(position);
1671 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1672 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1673 return v;
1674 }
1675 };
1676 }
1677 protected Element mValue = null;
1678 protected ArrayAdapter<Option> options;
1679
1680 @Override
1681 public void bind(Item item) {
1682 Field field = (Field) item;
1683 setTextOrHide(binding.label, field.getLabel());
1684 setTextOrHide(binding.desc, field.getDesc());
1685
1686 if (field.error != null) {
1687 binding.desc.setVisibility(View.VISIBLE);
1688 binding.desc.setText(field.error);
1689 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1690 } else {
1691 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1692 }
1693
1694 mValue = field.getValue();
1695
1696 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1697 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1698 binding.open.setText(mValue.getContent());
1699 setupInputType(field.el, binding.open, null);
1700
1701 options.clear();
1702 List<Option> theOptions = field.getOptions();
1703 options.addAll(theOptions);
1704
1705 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1706 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1707 float maxColumnWidth = theOptions.stream().map((x) ->
1708 StaticLayout.getDesiredWidth(x.toString(), paint)
1709 ).max(Float::compare).orElse(new Float(0.0));
1710 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1711 binding.radios.setNumColumns(theOptions.size());
1712 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1713 binding.radios.setNumColumns(theOptions.size() / 2);
1714 } else {
1715 binding.radios.setNumColumns(1);
1716 }
1717 binding.radios.setAdapter(options);
1718 }
1719
1720 @Override
1721 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1722 if (mValue == null) return;
1723
1724 if (isChecked) {
1725 mValue.setContent(options.getItem(radio.getId()).getValue());
1726 binding.open.setText(mValue.getContent());
1727 }
1728 options.notifyDataSetChanged();
1729 }
1730
1731 @Override
1732 public void afterTextChanged(Editable s) {
1733 if (mValue == null) return;
1734
1735 mValue.setContent(s.toString());
1736 options.notifyDataSetChanged();
1737 }
1738
1739 @Override
1740 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1741
1742 @Override
1743 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1744 }
1745
1746 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1747 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1748 super(binding);
1749 binding.spinner.setOnItemSelectedListener(this);
1750 }
1751 protected Element mValue = null;
1752
1753 @Override
1754 public void bind(Item item) {
1755 Field field = (Field) item;
1756 setTextOrHide(binding.label, field.getLabel());
1757 binding.spinner.setPrompt(field.getLabel().or(""));
1758 setTextOrHide(binding.desc, field.getDesc());
1759
1760 mValue = field.getValue();
1761
1762 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1763 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1764 options.addAll(field.getOptions());
1765
1766 binding.spinner.setAdapter(options);
1767 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1768 }
1769
1770 @Override
1771 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1772 Option o = (Option) parent.getItemAtPosition(pos);
1773 if (mValue == null) return;
1774
1775 mValue.setContent(o == null ? "" : o.getValue());
1776 }
1777
1778 @Override
1779 public void onNothingSelected(AdapterView<?> parent) {
1780 mValue.setContent("");
1781 }
1782 }
1783
1784 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1785 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1786 super(binding);
1787 binding.textinput.addTextChangedListener(this);
1788 }
1789 protected Element mValue = null;
1790
1791 @Override
1792 public void bind(Item item) {
1793 Field field = (Field) item;
1794 binding.textinputLayout.setHint(field.getLabel().or(""));
1795
1796 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1797 for (String desc : field.getDesc().asSet()) {
1798 binding.textinputLayout.setHelperText(desc);
1799 }
1800
1801 binding.textinputLayout.setErrorEnabled(field.error != null);
1802 if (field.error != null) binding.textinputLayout.setError(field.error);
1803
1804 mValue = field.getValue();
1805 binding.textinput.setText(mValue.getContent());
1806 setupInputType(field.el, binding.textinput, binding.textinputLayout);
1807 }
1808
1809 @Override
1810 public void afterTextChanged(Editable s) {
1811 if (mValue == null) return;
1812
1813 mValue.setContent(s.toString());
1814 }
1815
1816 @Override
1817 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1818
1819 @Override
1820 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1821 }
1822
1823 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1824 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1825 protected String boundUrl = "";
1826
1827 @Override
1828 public void bind(Item oob) {
1829 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1830 binding.webview.getSettings().setJavaScriptEnabled(true);
1831 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");
1832 binding.webview.getSettings().setDatabaseEnabled(true);
1833 binding.webview.getSettings().setDomStorageEnabled(true);
1834 binding.webview.setWebChromeClient(new WebChromeClient() {
1835 @Override
1836 public void onProgressChanged(WebView view, int newProgress) {
1837 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1838 binding.progressbar.setProgress(newProgress);
1839 }
1840 });
1841 binding.webview.setWebViewClient(new WebViewClient() {
1842 @Override
1843 public void onPageFinished(WebView view, String url) {
1844 super.onPageFinished(view, url);
1845 mTitle = view.getTitle();
1846 ConversationPagerAdapter.this.notifyDataSetChanged();
1847 }
1848 });
1849 final String url = oob.el.findChildContent("url", "jabber:x:oob");
1850 if (!boundUrl.equals(url)) {
1851 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1852 binding.webview.loadUrl(url);
1853 boundUrl = url;
1854 }
1855 }
1856
1857 class JsObject {
1858 @JavascriptInterface
1859 public void execute() { execute("execute"); }
1860
1861 @JavascriptInterface
1862 public void execute(String action) {
1863 getView().post(() -> {
1864 actionToWebview = null;
1865 if(CommandSession.this.execute(action)) {
1866 removeSession(CommandSession.this);
1867 }
1868 });
1869 }
1870
1871 @JavascriptInterface
1872 public void preventDefault() {
1873 actionToWebview = binding.webview;
1874 }
1875 }
1876 }
1877
1878 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1879 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1880
1881 @Override
1882 public void bind(Item item) { }
1883 }
1884
1885 class Item {
1886 protected Element el;
1887 protected int viewType;
1888 protected String error = null;
1889
1890 Item(Element el, int viewType) {
1891 this.el = el;
1892 this.viewType = viewType;
1893 }
1894
1895 public boolean validate() {
1896 error = null;
1897 return true;
1898 }
1899 }
1900
1901 class Field extends Item {
1902 Field(Element el, int viewType) { super(el, viewType); }
1903
1904 @Override
1905 public boolean validate() {
1906 if (!super.validate()) return false;
1907 if (el.findChild("required", "jabber:x:data") == null) return true;
1908 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1909
1910 error = "this value is required";
1911 return false;
1912 }
1913
1914 public String getVar() {
1915 return el.getAttribute("var");
1916 }
1917
1918 public Optional<String> getType() {
1919 return Optional.fromNullable(el.getAttribute("type"));
1920 }
1921
1922 public Optional<String> getLabel() {
1923 String label = el.getAttribute("label");
1924 if (label == null) label = getVar();
1925 return Optional.fromNullable(label);
1926 }
1927
1928 public Optional<String> getDesc() {
1929 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1930 }
1931
1932 public Element getValue() {
1933 Element value = el.findChild("value", "jabber:x:data");
1934 if (value == null) {
1935 value = el.addChild("value", "jabber:x:data");
1936 }
1937 return value;
1938 }
1939
1940 public List<Option> getOptions() {
1941 return Option.forField(el);
1942 }
1943 }
1944
1945 class Cell extends Item {
1946 protected Field reported;
1947
1948 Cell(Field reported, Element item) {
1949 super(item, TYPE_RESULT_CELL);
1950 this.reported = reported;
1951 }
1952 }
1953
1954 protected Field mkField(Element el) {
1955 int viewType = -1;
1956
1957 String formType = responseElement.getAttribute("type");
1958 if (formType != null) {
1959 String fieldType = el.getAttribute("type");
1960 if (fieldType == null) fieldType = "text-single";
1961
1962 if (formType.equals("result") || fieldType.equals("fixed")) {
1963 viewType = TYPE_RESULT_FIELD;
1964 } else if (formType.equals("form")) {
1965 if (fieldType.equals("boolean")) {
1966 viewType = TYPE_CHECKBOX_FIELD;
1967 } else if (fieldType.equals("list-single")) {
1968 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1969 if (Option.forField(el).size() > 9) {
1970 viewType = TYPE_SEARCH_LIST_FIELD;
1971 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1972 viewType = TYPE_RADIO_EDIT_FIELD;
1973 } else {
1974 viewType = TYPE_SPINNER_FIELD;
1975 }
1976 } else {
1977 viewType = TYPE_TEXT_FIELD;
1978 }
1979 }
1980
1981 return new Field(el, viewType);
1982 }
1983
1984 return null;
1985 }
1986
1987 protected Item mkItem(Element el, int pos) {
1988 int viewType = -1;
1989
1990 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
1991 if (el.getName().equals("note")) {
1992 viewType = TYPE_NOTE;
1993 } else if (el.getNamespace().equals("jabber:x:oob")) {
1994 viewType = TYPE_WEB;
1995 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
1996 viewType = TYPE_NOTE;
1997 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
1998 Field field = mkField(el);
1999 if (field != null) {
2000 items.put(pos, field);
2001 return field;
2002 }
2003 }
2004 } else if (response != null) {
2005 viewType = TYPE_ERROR;
2006 }
2007
2008 Item item = new Item(el, viewType);
2009 items.put(pos, item);
2010 return item;
2011 }
2012
2013 final int TYPE_ERROR = 1;
2014 final int TYPE_NOTE = 2;
2015 final int TYPE_WEB = 3;
2016 final int TYPE_RESULT_FIELD = 4;
2017 final int TYPE_TEXT_FIELD = 5;
2018 final int TYPE_CHECKBOX_FIELD = 6;
2019 final int TYPE_SPINNER_FIELD = 7;
2020 final int TYPE_RADIO_EDIT_FIELD = 8;
2021 final int TYPE_RESULT_CELL = 9;
2022 final int TYPE_PROGRESSBAR = 10;
2023 final int TYPE_SEARCH_LIST_FIELD = 11;
2024
2025 protected boolean loading = false;
2026 protected Timer loadingTimer = new Timer();
2027 protected String mTitle;
2028 protected CommandPageBinding mBinding = null;
2029 protected IqPacket response = null;
2030 protected Element responseElement = null;
2031 protected List<Field> reported = null;
2032 protected SparseArray<Item> items = new SparseArray<>();
2033 protected XmppConnectionService xmppConnectionService;
2034 protected ArrayAdapter<String> actionsAdapter;
2035 protected GridLayoutManager layoutManager;
2036 protected WebView actionToWebview = null;
2037
2038 CommandSession(String title, XmppConnectionService xmppConnectionService) {
2039 loading();
2040 mTitle = title;
2041 this.xmppConnectionService = xmppConnectionService;
2042 if (mPager != null) setupLayoutManager();
2043 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2044 @Override
2045 public View getView(int position, View convertView, ViewGroup parent) {
2046 View v = super.getView(position, convertView, parent);
2047 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2048 tv.setGravity(Gravity.CENTER);
2049 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2050 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2051 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2052 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2053 return v;
2054 }
2055 };
2056 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2057 @Override
2058 public void onChanged() {
2059 if (mBinding == null) return;
2060
2061 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2062 }
2063
2064 @Override
2065 public void onInvalidated() {}
2066 });
2067 }
2068
2069 public String getTitle() {
2070 return mTitle;
2071 }
2072
2073 public void updateWithResponse(IqPacket iq) {
2074 this.loadingTimer.cancel();
2075 this.loadingTimer = new Timer();
2076 this.loading = false;
2077 this.responseElement = null;
2078 this.reported = null;
2079 this.response = iq;
2080 this.items.clear();
2081 this.actionsAdapter.clear();
2082 layoutManager.setSpanCount(1);
2083
2084 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2085 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2086 for (Element el : command.getChildren()) {
2087 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2088 for (Element action : el.getChildren()) {
2089 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2090 if (action.getName().equals("execute")) continue;
2091
2092 actionsAdapter.add(action.getName());
2093 }
2094 }
2095 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2096 String title = el.findChildContent("title", "jabber:x:data");
2097 if (title != null) {
2098 mTitle = title;
2099 ConversationPagerAdapter.this.notifyDataSetChanged();
2100 }
2101
2102 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2103 this.responseElement = el;
2104 setupReported(el.findChild("reported", "jabber:x:data"));
2105 layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2106 }
2107 break;
2108 }
2109 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2110 String url = el.findChildContent("url", "jabber:x:oob");
2111 if (url != null) {
2112 String scheme = Uri.parse(url).getScheme();
2113 if (scheme.equals("http") || scheme.equals("https")) {
2114 this.responseElement = el;
2115 break;
2116 }
2117 }
2118 }
2119 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2120 this.responseElement = el;
2121 break;
2122 }
2123 }
2124
2125 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2126 removeSession(this);
2127 return;
2128 }
2129
2130 if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2131 // No actions have been given, but we are not done?
2132 // This is probably a spec violation, but we should do *something*
2133 actionsAdapter.add("execute");
2134 }
2135
2136 if (!actionsAdapter.isEmpty()) {
2137 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2138 actionsAdapter.add("close");
2139 } else if (actionsAdapter.getPosition("cancel") < 0) {
2140 actionsAdapter.insert("cancel", 0);
2141 }
2142 }
2143 }
2144
2145 if (actionsAdapter.isEmpty()) {
2146 actionsAdapter.add("close");
2147 }
2148
2149 notifyDataSetChanged();
2150 }
2151
2152 protected void setupReported(Element el) {
2153 if (el == null) {
2154 reported = null;
2155 return;
2156 }
2157
2158 reported = new ArrayList<>();
2159 for (Element fieldEl : el.getChildren()) {
2160 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2161 reported.add(mkField(fieldEl));
2162 }
2163 }
2164
2165 @Override
2166 public int getItemCount() {
2167 if (loading) return 1;
2168 if (response == null) return 0;
2169 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2170 int i = 0;
2171 for (Element el : responseElement.getChildren()) {
2172 if (!el.getNamespace().equals("jabber:x:data")) continue;
2173 if (el.getName().equals("title")) continue;
2174 if (el.getName().equals("field")) {
2175 String type = el.getAttribute("type");
2176 if (type != null && type.equals("hidden")) continue;
2177 }
2178
2179 if (el.getName().equals("reported") || el.getName().equals("item")) {
2180 if (reported != null) i += reported.size();
2181 continue;
2182 }
2183
2184 i++;
2185 }
2186 return i;
2187 }
2188 return 1;
2189 }
2190
2191 public Item getItem(int position) {
2192 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2193 if (items.get(position) != null) return items.get(position);
2194 if (response == null) return null;
2195
2196 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2197 if (responseElement.getNamespace().equals("jabber:x:data")) {
2198 int i = 0;
2199 for (Element el : responseElement.getChildren()) {
2200 if (!el.getNamespace().equals("jabber:x:data")) continue;
2201 if (el.getName().equals("title")) continue;
2202 if (el.getName().equals("field")) {
2203 String type = el.getAttribute("type");
2204 if (type != null && type.equals("hidden")) continue;
2205 }
2206
2207 if (el.getName().equals("reported") || el.getName().equals("item")) {
2208 Cell cell = null;
2209
2210 if (reported != null) {
2211 if (reported.size() > position - i) {
2212 Field reportedField = reported.get(position - i);
2213 Element itemField = null;
2214 if (el.getName().equals("item")) {
2215 for (Element subel : el.getChildren()) {
2216 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2217 itemField = subel;
2218 break;
2219 }
2220 }
2221 }
2222 cell = new Cell(reportedField, itemField);
2223 } else {
2224 i += reported.size();
2225 continue;
2226 }
2227 }
2228
2229 if (cell != null) {
2230 items.put(position, cell);
2231 return cell;
2232 }
2233 }
2234
2235 if (i < position) {
2236 i++;
2237 continue;
2238 }
2239
2240 return mkItem(el, position);
2241 }
2242 }
2243 }
2244
2245 return mkItem(responseElement == null ? response : responseElement, position);
2246 }
2247
2248 @Override
2249 public int getItemViewType(int position) {
2250 return getItem(position).viewType;
2251 }
2252
2253 @Override
2254 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2255 switch(viewType) {
2256 case TYPE_ERROR: {
2257 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2258 return new ErrorViewHolder(binding);
2259 }
2260 case TYPE_NOTE: {
2261 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2262 return new NoteViewHolder(binding);
2263 }
2264 case TYPE_WEB: {
2265 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2266 return new WebViewHolder(binding);
2267 }
2268 case TYPE_RESULT_FIELD: {
2269 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2270 return new ResultFieldViewHolder(binding);
2271 }
2272 case TYPE_RESULT_CELL: {
2273 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2274 return new ResultCellViewHolder(binding);
2275 }
2276 case TYPE_CHECKBOX_FIELD: {
2277 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2278 return new CheckboxFieldViewHolder(binding);
2279 }
2280 case TYPE_SEARCH_LIST_FIELD: {
2281 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2282 return new SearchListFieldViewHolder(binding);
2283 }
2284 case TYPE_RADIO_EDIT_FIELD: {
2285 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2286 return new RadioEditFieldViewHolder(binding);
2287 }
2288 case TYPE_SPINNER_FIELD: {
2289 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2290 return new SpinnerFieldViewHolder(binding);
2291 }
2292 case TYPE_TEXT_FIELD: {
2293 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2294 return new TextFieldViewHolder(binding);
2295 }
2296 case TYPE_PROGRESSBAR: {
2297 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2298 return new ProgressBarViewHolder(binding);
2299 }
2300 default:
2301 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2302 }
2303 }
2304
2305 @Override
2306 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2307 viewHolder.bind(getItem(position));
2308 }
2309
2310 public View getView() {
2311 return mBinding.getRoot();
2312 }
2313
2314 public boolean validate() {
2315 int count = getItemCount();
2316 boolean isValid = true;
2317 for (int i = 0; i < count; i++) {
2318 boolean oneIsValid = getItem(i).validate();
2319 isValid = isValid && oneIsValid;
2320 }
2321 notifyDataSetChanged();
2322 return isValid;
2323 }
2324
2325 public boolean execute() {
2326 return execute("execute");
2327 }
2328
2329 public boolean execute(int actionPosition) {
2330 return execute(actionsAdapter.getItem(actionPosition));
2331 }
2332
2333 public boolean execute(String action) {
2334 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2335
2336 if (response == null) return true;
2337 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2338 if (command == null) return true;
2339 String status = command.getAttribute("status");
2340 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2341
2342 if (actionToWebview != null) {
2343 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2344 return false;
2345 }
2346
2347 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2348 packet.setTo(response.getFrom());
2349 final Element c = packet.addChild("command", Namespace.COMMANDS);
2350 c.setAttribute("node", command.getAttribute("node"));
2351 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2352 c.setAttribute("action", action);
2353
2354 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2355 if (!action.equals("cancel") &&
2356 !action.equals("prev") &&
2357 responseElement != null &&
2358 responseElement.getName().equals("x") &&
2359 responseElement.getNamespace().equals("jabber:x:data") &&
2360 formType != null && formType.equals("form")) {
2361
2362 responseElement.setAttribute("type", "submit");
2363 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2364 if (rsm != null) {
2365 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2366 max.setContent("1000");
2367 rsm.addChild(max);
2368 }
2369 c.addChild(responseElement);
2370 }
2371
2372 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2373 getView().post(() -> {
2374 updateWithResponse(iq);
2375 });
2376 });
2377
2378 loading();
2379 return false;
2380 }
2381
2382 protected void loading() {
2383 loadingTimer.schedule(new TimerTask() {
2384 @Override
2385 public void run() {
2386 getView().post(() -> {
2387 loading = true;
2388 notifyDataSetChanged();
2389 });
2390 }
2391 }, 500);
2392 }
2393
2394 protected GridLayoutManager setupLayoutManager() {
2395 layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2396 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2397 @Override
2398 public int getSpanSize(int position) {
2399 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2400 return 1;
2401 }
2402 });
2403 return layoutManager;
2404 }
2405
2406 public void setBinding(CommandPageBinding b) {
2407 mBinding = b;
2408 // https://stackoverflow.com/a/32350474/8611
2409 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2410 @Override
2411 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2412 if(rv.getChildCount() > 0) {
2413 int[] location = new int[2];
2414 rv.getLocationOnScreen(location);
2415 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2416 if (childView instanceof ViewGroup) {
2417 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2418 }
2419 if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2420 int action = e.getAction();
2421 switch (action) {
2422 case MotionEvent.ACTION_DOWN:
2423 rv.requestDisallowInterceptTouchEvent(true);
2424 }
2425 }
2426 }
2427
2428 return false;
2429 }
2430
2431 @Override
2432 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2433
2434 @Override
2435 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2436 });
2437 mBinding.form.setLayoutManager(setupLayoutManager());
2438 mBinding.form.setAdapter(this);
2439 mBinding.actions.setAdapter(actionsAdapter);
2440 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2441 if (execute(pos)) {
2442 removeSession(CommandSession.this);
2443 }
2444 });
2445
2446 actionsAdapter.notifyDataSetChanged();
2447 }
2448
2449 // https://stackoverflow.com/a/36037991/8611
2450 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2451 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2452 View child = viewGroup.getChildAt(i);
2453 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2454 View foundView = findViewAt((ViewGroup) child, x, y);
2455 if (foundView != null && foundView.isShown()) {
2456 return foundView;
2457 }
2458 } else {
2459 int[] location = new int[2];
2460 child.getLocationOnScreen(location);
2461 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2462 if (rect.contains((int)x, (int)y)) {
2463 return child;
2464 }
2465 }
2466 }
2467
2468 return null;
2469 }
2470 }
2471 }
2472}