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