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
1239 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1240 mPager = pager;
1241 mTabs = tabs;
1242
1243 if (mPager == null) return;
1244 if (sessions != null) show();
1245
1246 pager.setAdapter(this);
1247 tabs.setupWithViewPager(mPager);
1248 pager.setCurrentItem(getCurrentTab());
1249
1250 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1251 public void onPageScrollStateChanged(int state) { }
1252 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1253
1254 public void onPageSelected(int position) {
1255 setCurrentTab(position);
1256 }
1257 });
1258 }
1259
1260 public void show() {
1261 if (sessions == null) {
1262 sessions = new ArrayList<>();
1263 notifyDataSetChanged();
1264 }
1265 if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1266 }
1267
1268 public void hide() {
1269 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1270 if (mPager != null) mPager.setCurrentItem(0);
1271 if (mTabs != null) mTabs.setVisibility(View.GONE);
1272 sessions = null;
1273 notifyDataSetChanged();
1274 }
1275
1276 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1277 show();
1278 CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1279
1280 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1281 packet.setTo(command.getAttributeAsJid("jid"));
1282 final Element c = packet.addChild("command", Namespace.COMMANDS);
1283 c.setAttribute("node", command.getAttribute("node"));
1284 c.setAttribute("action", "execute");
1285 View v = mPager;
1286 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1287 v.post(() -> {
1288 session.updateWithResponse(iq);
1289 });
1290 });
1291
1292 sessions.add(session);
1293 notifyDataSetChanged();
1294 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1295 }
1296
1297 public void removeSession(CommandSession session) {
1298 sessions.remove(session);
1299 notifyDataSetChanged();
1300 }
1301
1302 @NonNull
1303 @Override
1304 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1305 if (position < 2) {
1306 return mPager.getChildAt(position);
1307 }
1308
1309 CommandSession session = sessions.get(position-2);
1310 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1311 container.addView(binding.getRoot());
1312 session.setBinding(binding);
1313 return session;
1314 }
1315
1316 @Override
1317 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1318 if (position < 2) return;
1319
1320 container.removeView(((CommandSession) o).getView());
1321 }
1322
1323 @Override
1324 public int getItemPosition(Object o) {
1325 if (mPager != null) {
1326 if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
1327 if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;
1328 }
1329
1330 int pos = sessions == null ? -1 : sessions.indexOf(o);
1331 if (pos < 0) return PagerAdapter.POSITION_NONE;
1332 return pos + 2;
1333 }
1334
1335 @Override
1336 public int getCount() {
1337 if (sessions == null) return 1;
1338
1339 int count = 2 + sessions.size();
1340 if (mTabs == null) return count;
1341
1342 if (count > 2) {
1343 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1344 } else {
1345 mTabs.setTabMode(TabLayout.MODE_FIXED);
1346 }
1347 return count;
1348 }
1349
1350 @Override
1351 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1352 if (view == o) return true;
1353
1354 if (o instanceof CommandSession) {
1355 return ((CommandSession) o).getView() == view;
1356 }
1357
1358 return false;
1359 }
1360
1361 @Nullable
1362 @Override
1363 public CharSequence getPageTitle(int position) {
1364 switch (position) {
1365 case 0:
1366 return "Conversation";
1367 case 1:
1368 return "Commands";
1369 default:
1370 CommandSession session = sessions.get(position-2);
1371 if (session == null) return super.getPageTitle(position);
1372 return session.getTitle();
1373 }
1374 }
1375
1376 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1377 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1378 protected T binding;
1379
1380 public ViewHolder(T binding) {
1381 super(binding.getRoot());
1382 this.binding = binding;
1383 }
1384
1385 abstract public void bind(Item el);
1386
1387 protected void setTextOrHide(TextView v, Optional<String> s) {
1388 if (s == null || !s.isPresent()) {
1389 v.setVisibility(View.GONE);
1390 } else {
1391 v.setVisibility(View.VISIBLE);
1392 v.setText(s.get());
1393 }
1394 }
1395
1396 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1397 int flags = 0;
1398 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1399 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1400
1401 String type = field.getAttribute("type");
1402 if (type != null) {
1403 if (type.equals("text-multi") || type.equals("jid-multi")) {
1404 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1405 }
1406
1407 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1408
1409 if (type.equals("jid-single") || type.equals("jid-multi")) {
1410 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1411 }
1412
1413 if (type.equals("text-private")) {
1414 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1415 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1416 }
1417 }
1418
1419 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1420 if (validate == null) return;
1421 String datatype = validate.getAttribute("datatype");
1422 if (datatype == null) return;
1423
1424 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1425 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1426 }
1427
1428 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1429 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1430 }
1431
1432 if (datatype.equals("xs:date")) {
1433 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1434 }
1435
1436 if (datatype.equals("xs:dateTime")) {
1437 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1438 }
1439
1440 if (datatype.equals("xs:time")) {
1441 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1442 }
1443
1444 if (datatype.equals("xs:anyURI")) {
1445 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1446 }
1447
1448 if (datatype.equals("html:tel")) {
1449 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1450 }
1451
1452 if (datatype.equals("html:email")) {
1453 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1454 }
1455 }
1456 }
1457
1458 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1459 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1460
1461 @Override
1462 public void bind(Item iq) {
1463 binding.errorIcon.setVisibility(View.VISIBLE);
1464
1465 Element error = iq.el.findChild("error");
1466 if (error == null) return;
1467 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1468 if (text == null || text.equals("")) {
1469 text = error.getChildren().get(0).getName();
1470 }
1471 binding.message.setText(text);
1472 }
1473 }
1474
1475 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1476 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1477
1478 @Override
1479 public void bind(Item note) {
1480 binding.message.setText(note.el.getContent());
1481
1482 String type = note.el.getAttribute("type");
1483 if (type != null && type.equals("error")) {
1484 binding.errorIcon.setVisibility(View.VISIBLE);
1485 }
1486 }
1487 }
1488
1489 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1490 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1491
1492 @Override
1493 public void bind(Item item) {
1494 Field field = (Field) item;
1495 setTextOrHide(binding.label, field.getLabel());
1496 setTextOrHide(binding.desc, field.getDesc());
1497
1498 ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1499 for (Element el : field.el.getChildren()) {
1500 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1501 values.add(el.getContent());
1502 }
1503 }
1504 binding.values.setAdapter(values);
1505
1506 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1507 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1508 new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1509 });
1510 }
1511
1512 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1513 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1514 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1515 }
1516 return true;
1517 });
1518 }
1519 }
1520
1521 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1522 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1523
1524 @Override
1525 public void bind(Item item) {
1526 Cell cell = (Cell) item;
1527
1528 if (cell.el == null) {
1529 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1530 setTextOrHide(binding.text, cell.reported.getLabel());
1531 } else {
1532 SpannableStringBuilder text = new SpannableStringBuilder(cell.el.findChildContent("value", "jabber:x:data"));
1533 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1534 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1535 }
1536
1537 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1538 binding.text.setText(text);
1539
1540 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1541 method.setOnLinkLongClickListener((tv, url) -> {
1542 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1543 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1544 return true;
1545 });
1546 binding.text.setMovementMethod(method);
1547 }
1548 }
1549 }
1550
1551 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1552 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1553 super(binding);
1554 binding.row.setOnClickListener((v) -> {
1555 binding.checkbox.toggle();
1556 });
1557 binding.checkbox.setOnCheckedChangeListener(this);
1558 }
1559 protected Element mValue = null;
1560
1561 @Override
1562 public void bind(Item item) {
1563 Field field = (Field) item;
1564 binding.label.setText(field.getLabel().or(""));
1565 setTextOrHide(binding.desc, field.getDesc());
1566 mValue = field.getValue();
1567 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1568 }
1569
1570 @Override
1571 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1572 if (mValue == null) return;
1573
1574 mValue.setContent(isChecked ? "true" : "false");
1575 }
1576 }
1577
1578 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1579 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1580 super(binding);
1581 binding.search.addTextChangedListener(this);
1582 }
1583 protected Element mValue = null;
1584 List<Option> options = new ArrayList<>();
1585 protected ArrayAdapter<Option> adapter;
1586 protected boolean open;
1587
1588 @Override
1589 public void bind(Item item) {
1590 Field field = (Field) item;
1591 setTextOrHide(binding.label, field.getLabel());
1592 setTextOrHide(binding.desc, field.getDesc());
1593
1594 if (field.error != null) {
1595 binding.desc.setVisibility(View.VISIBLE);
1596 binding.desc.setText(field.error);
1597 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1598 } else {
1599 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1600 }
1601
1602 mValue = field.getValue();
1603
1604 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1605 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1606 setupInputType(field.el, binding.search, null);
1607
1608 options = field.getOptions();
1609 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1610 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1611 if (open) binding.search.setText(mValue.getContent());
1612 });
1613 search("");
1614 }
1615
1616 @Override
1617 public void afterTextChanged(Editable s) {
1618 if (open) mValue.setContent(s.toString());
1619 search(s.toString());
1620 }
1621
1622 @Override
1623 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1624
1625 @Override
1626 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1627
1628 protected void search(String s) {
1629 List<Option> filteredOptions;
1630 final String q = s.replaceAll("\\W", "").toLowerCase();
1631 if (q == null || q.equals("")) {
1632 filteredOptions = options;
1633 } else {
1634 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1635 }
1636 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1637 binding.list.setAdapter(adapter);
1638
1639 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1640 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1641 }
1642 }
1643
1644 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1645 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1646 super(binding);
1647 binding.open.addTextChangedListener(this);
1648 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1649 @Override
1650 public View getView(int position, View convertView, ViewGroup parent) {
1651 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1652 v.setId(position);
1653 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1654 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1655 return v;
1656 }
1657 };
1658 }
1659 protected Element mValue = null;
1660 protected ArrayAdapter<Option> options;
1661
1662 @Override
1663 public void bind(Item item) {
1664 Field field = (Field) item;
1665 setTextOrHide(binding.label, field.getLabel());
1666 setTextOrHide(binding.desc, field.getDesc());
1667
1668 if (field.error != null) {
1669 binding.desc.setVisibility(View.VISIBLE);
1670 binding.desc.setText(field.error);
1671 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1672 } else {
1673 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1674 }
1675
1676 mValue = field.getValue();
1677
1678 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1679 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1680 binding.open.setText(mValue.getContent());
1681 setupInputType(field.el, binding.open, null);
1682
1683 options.clear();
1684 List<Option> theOptions = field.getOptions();
1685 options.addAll(theOptions);
1686
1687 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1688 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1689 float maxColumnWidth = theOptions.stream().map((x) ->
1690 StaticLayout.getDesiredWidth(x.toString(), paint)
1691 ).max(Float::compare).orElse(new Float(0.0));
1692 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1693 binding.radios.setNumColumns(theOptions.size());
1694 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1695 binding.radios.setNumColumns(theOptions.size() / 2);
1696 } else {
1697 binding.radios.setNumColumns(1);
1698 }
1699 binding.radios.setAdapter(options);
1700 }
1701
1702 @Override
1703 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1704 if (mValue == null) return;
1705
1706 if (isChecked) {
1707 mValue.setContent(options.getItem(radio.getId()).getValue());
1708 binding.open.setText(mValue.getContent());
1709 }
1710 options.notifyDataSetChanged();
1711 }
1712
1713 @Override
1714 public void afterTextChanged(Editable s) {
1715 if (mValue == null) return;
1716
1717 mValue.setContent(s.toString());
1718 options.notifyDataSetChanged();
1719 }
1720
1721 @Override
1722 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1723
1724 @Override
1725 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1726 }
1727
1728 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1729 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1730 super(binding);
1731 binding.spinner.setOnItemSelectedListener(this);
1732 }
1733 protected Element mValue = null;
1734
1735 @Override
1736 public void bind(Item item) {
1737 Field field = (Field) item;
1738 setTextOrHide(binding.label, field.getLabel());
1739 binding.spinner.setPrompt(field.getLabel().or(""));
1740 setTextOrHide(binding.desc, field.getDesc());
1741
1742 mValue = field.getValue();
1743
1744 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1745 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1746 options.addAll(field.getOptions());
1747
1748 binding.spinner.setAdapter(options);
1749 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1750 }
1751
1752 @Override
1753 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1754 Option o = (Option) parent.getItemAtPosition(pos);
1755 if (mValue == null) return;
1756
1757 mValue.setContent(o == null ? "" : o.getValue());
1758 }
1759
1760 @Override
1761 public void onNothingSelected(AdapterView<?> parent) {
1762 mValue.setContent("");
1763 }
1764 }
1765
1766 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1767 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1768 super(binding);
1769 binding.textinput.addTextChangedListener(this);
1770 }
1771 protected Element mValue = null;
1772
1773 @Override
1774 public void bind(Item item) {
1775 Field field = (Field) item;
1776 binding.textinputLayout.setHint(field.getLabel().or(""));
1777
1778 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1779 for (String desc : field.getDesc().asSet()) {
1780 binding.textinputLayout.setHelperText(desc);
1781 }
1782
1783 binding.textinputLayout.setErrorEnabled(field.error != null);
1784 if (field.error != null) binding.textinputLayout.setError(field.error);
1785
1786 mValue = field.getValue();
1787 binding.textinput.setText(mValue.getContent());
1788 setupInputType(field.el, binding.textinput, binding.textinputLayout);
1789 }
1790
1791 @Override
1792 public void afterTextChanged(Editable s) {
1793 if (mValue == null) return;
1794
1795 mValue.setContent(s.toString());
1796 }
1797
1798 @Override
1799 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1800
1801 @Override
1802 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1803 }
1804
1805 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1806 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1807 protected String boundUrl = "";
1808
1809 @Override
1810 public void bind(Item oob) {
1811 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1812 binding.webview.getSettings().setJavaScriptEnabled(true);
1813 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");
1814 binding.webview.getSettings().setDatabaseEnabled(true);
1815 binding.webview.getSettings().setDomStorageEnabled(true);
1816 binding.webview.setWebChromeClient(new WebChromeClient() {
1817 @Override
1818 public void onProgressChanged(WebView view, int newProgress) {
1819 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1820 binding.progressbar.setProgress(newProgress);
1821 }
1822 });
1823 binding.webview.setWebViewClient(new WebViewClient() {
1824 @Override
1825 public void onPageFinished(WebView view, String url) {
1826 super.onPageFinished(view, url);
1827 mTitle = view.getTitle();
1828 ConversationPagerAdapter.this.notifyDataSetChanged();
1829 }
1830 });
1831 final String url = oob.el.findChildContent("url", "jabber:x:oob");
1832 if (!boundUrl.equals(url)) {
1833 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1834 binding.webview.loadUrl(url);
1835 boundUrl = url;
1836 }
1837 }
1838
1839 class JsObject {
1840 @JavascriptInterface
1841 public void execute() { execute("execute"); }
1842
1843 @JavascriptInterface
1844 public void execute(String action) {
1845 getView().post(() -> {
1846 actionToWebview = null;
1847 if(CommandSession.this.execute(action)) {
1848 removeSession(CommandSession.this);
1849 }
1850 });
1851 }
1852
1853 @JavascriptInterface
1854 public void preventDefault() {
1855 actionToWebview = binding.webview;
1856 }
1857 }
1858 }
1859
1860 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1861 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1862
1863 @Override
1864 public void bind(Item item) { }
1865 }
1866
1867 class Item {
1868 protected Element el;
1869 protected int viewType;
1870 protected String error = null;
1871
1872 Item(Element el, int viewType) {
1873 this.el = el;
1874 this.viewType = viewType;
1875 }
1876
1877 public boolean validate() {
1878 error = null;
1879 return true;
1880 }
1881 }
1882
1883 class Field extends Item {
1884 Field(Element el, int viewType) { super(el, viewType); }
1885
1886 @Override
1887 public boolean validate() {
1888 if (!super.validate()) return false;
1889 if (el.findChild("required", "jabber:x:data") == null) return true;
1890 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1891
1892 error = "this value is required";
1893 return false;
1894 }
1895
1896 public String getVar() {
1897 return el.getAttribute("var");
1898 }
1899
1900 public Optional<String> getType() {
1901 return Optional.fromNullable(el.getAttribute("type"));
1902 }
1903
1904 public Optional<String> getLabel() {
1905 String label = el.getAttribute("label");
1906 if (label == null) label = getVar();
1907 return Optional.fromNullable(label);
1908 }
1909
1910 public Optional<String> getDesc() {
1911 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1912 }
1913
1914 public Element getValue() {
1915 Element value = el.findChild("value", "jabber:x:data");
1916 if (value == null) {
1917 value = el.addChild("value", "jabber:x:data");
1918 }
1919 return value;
1920 }
1921
1922 public List<Option> getOptions() {
1923 return Option.forField(el);
1924 }
1925 }
1926
1927 class Cell extends Item {
1928 protected Field reported;
1929
1930 Cell(Field reported, Element item) {
1931 super(item, TYPE_RESULT_CELL);
1932 this.reported = reported;
1933 }
1934 }
1935
1936 protected Field mkField(Element el) {
1937 int viewType = -1;
1938
1939 String formType = responseElement.getAttribute("type");
1940 if (formType != null) {
1941 String fieldType = el.getAttribute("type");
1942 if (fieldType == null) fieldType = "text-single";
1943
1944 if (formType.equals("result") || fieldType.equals("fixed")) {
1945 viewType = TYPE_RESULT_FIELD;
1946 } else if (formType.equals("form")) {
1947 if (fieldType.equals("boolean")) {
1948 viewType = TYPE_CHECKBOX_FIELD;
1949 } else if (fieldType.equals("list-single")) {
1950 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1951 if (Option.forField(el).size() > 9) {
1952 viewType = TYPE_SEARCH_LIST_FIELD;
1953 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1954 viewType = TYPE_RADIO_EDIT_FIELD;
1955 } else {
1956 viewType = TYPE_SPINNER_FIELD;
1957 }
1958 } else {
1959 viewType = TYPE_TEXT_FIELD;
1960 }
1961 }
1962
1963 return new Field(el, viewType);
1964 }
1965
1966 return null;
1967 }
1968
1969 protected Item mkItem(Element el, int pos) {
1970 int viewType = -1;
1971
1972 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
1973 if (el.getName().equals("note")) {
1974 viewType = TYPE_NOTE;
1975 } else if (el.getNamespace().equals("jabber:x:oob")) {
1976 viewType = TYPE_WEB;
1977 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
1978 viewType = TYPE_NOTE;
1979 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
1980 Field field = mkField(el);
1981 if (field != null) {
1982 items.put(pos, field);
1983 return field;
1984 }
1985 }
1986 } else if (response != null) {
1987 viewType = TYPE_ERROR;
1988 }
1989
1990 Item item = new Item(el, viewType);
1991 items.put(pos, item);
1992 return item;
1993 }
1994
1995 final int TYPE_ERROR = 1;
1996 final int TYPE_NOTE = 2;
1997 final int TYPE_WEB = 3;
1998 final int TYPE_RESULT_FIELD = 4;
1999 final int TYPE_TEXT_FIELD = 5;
2000 final int TYPE_CHECKBOX_FIELD = 6;
2001 final int TYPE_SPINNER_FIELD = 7;
2002 final int TYPE_RADIO_EDIT_FIELD = 8;
2003 final int TYPE_RESULT_CELL = 9;
2004 final int TYPE_PROGRESSBAR = 10;
2005 final int TYPE_SEARCH_LIST_FIELD = 11;
2006
2007 protected boolean loading = false;
2008 protected Timer loadingTimer = new Timer();
2009 protected String mTitle;
2010 protected CommandPageBinding mBinding = null;
2011 protected IqPacket response = null;
2012 protected Element responseElement = null;
2013 protected List<Field> reported = null;
2014 protected SparseArray<Item> items = new SparseArray<>();
2015 protected XmppConnectionService xmppConnectionService;
2016 protected ArrayAdapter<String> actionsAdapter;
2017 protected GridLayoutManager layoutManager;
2018 protected WebView actionToWebview = null;
2019
2020 CommandSession(String title, XmppConnectionService xmppConnectionService) {
2021 loading();
2022 mTitle = title;
2023 this.xmppConnectionService = xmppConnectionService;
2024 if (mPager != null) setupLayoutManager();
2025 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2026 @Override
2027 public View getView(int position, View convertView, ViewGroup parent) {
2028 View v = super.getView(position, convertView, parent);
2029 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2030 tv.setGravity(Gravity.CENTER);
2031 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2032 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2033 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2034 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2035 return v;
2036 }
2037 };
2038 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2039 @Override
2040 public void onChanged() {
2041 if (mBinding == null) return;
2042
2043 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2044 }
2045
2046 @Override
2047 public void onInvalidated() {}
2048 });
2049 }
2050
2051 public String getTitle() {
2052 return mTitle;
2053 }
2054
2055 public void updateWithResponse(IqPacket iq) {
2056 this.loadingTimer.cancel();
2057 this.loadingTimer = new Timer();
2058 this.loading = false;
2059 this.responseElement = null;
2060 this.reported = null;
2061 this.response = iq;
2062 this.items.clear();
2063 this.actionsAdapter.clear();
2064 layoutManager.setSpanCount(1);
2065
2066 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2067 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2068 for (Element el : command.getChildren()) {
2069 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2070 for (Element action : el.getChildren()) {
2071 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2072 if (action.getName().equals("execute")) continue;
2073
2074 actionsAdapter.add(action.getName());
2075 }
2076 }
2077 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2078 String title = el.findChildContent("title", "jabber:x:data");
2079 if (title != null) {
2080 mTitle = title;
2081 ConversationPagerAdapter.this.notifyDataSetChanged();
2082 }
2083
2084 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2085 this.responseElement = el;
2086 setupReported(el.findChild("reported", "jabber:x:data"));
2087 layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2088 }
2089 break;
2090 }
2091 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2092 String url = el.findChildContent("url", "jabber:x:oob");
2093 if (url != null) {
2094 String scheme = Uri.parse(url).getScheme();
2095 if (scheme.equals("http") || scheme.equals("https")) {
2096 this.responseElement = el;
2097 break;
2098 }
2099 }
2100 }
2101 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2102 this.responseElement = el;
2103 break;
2104 }
2105 }
2106
2107 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2108 removeSession(this);
2109 return;
2110 }
2111
2112 if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2113 // No actions have been given, but we are not done?
2114 // This is probably a spec violation, but we should do *something*
2115 actionsAdapter.add("execute");
2116 }
2117
2118 if (!actionsAdapter.isEmpty()) {
2119 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2120 actionsAdapter.add("close");
2121 } else if (actionsAdapter.getPosition("cancel") < 0) {
2122 actionsAdapter.insert("cancel", 0);
2123 }
2124 }
2125 }
2126
2127 if (actionsAdapter.isEmpty()) {
2128 actionsAdapter.add("close");
2129 }
2130
2131 notifyDataSetChanged();
2132 }
2133
2134 protected void setupReported(Element el) {
2135 if (el == null) {
2136 reported = null;
2137 return;
2138 }
2139
2140 reported = new ArrayList<>();
2141 for (Element fieldEl : el.getChildren()) {
2142 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2143 reported.add(mkField(fieldEl));
2144 }
2145 }
2146
2147 @Override
2148 public int getItemCount() {
2149 if (loading) return 1;
2150 if (response == null) return 0;
2151 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2152 int i = 0;
2153 for (Element el : responseElement.getChildren()) {
2154 if (!el.getNamespace().equals("jabber:x:data")) continue;
2155 if (el.getName().equals("title")) continue;
2156 if (el.getName().equals("field")) {
2157 String type = el.getAttribute("type");
2158 if (type != null && type.equals("hidden")) continue;
2159 }
2160
2161 if (el.getName().equals("reported") || el.getName().equals("item")) {
2162 if (reported != null) i += reported.size();
2163 continue;
2164 }
2165
2166 i++;
2167 }
2168 return i;
2169 }
2170 return 1;
2171 }
2172
2173 public Item getItem(int position) {
2174 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2175 if (items.get(position) != null) return items.get(position);
2176 if (response == null) return null;
2177
2178 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2179 if (responseElement.getNamespace().equals("jabber:x:data")) {
2180 int i = 0;
2181 for (Element el : responseElement.getChildren()) {
2182 if (!el.getNamespace().equals("jabber:x:data")) continue;
2183 if (el.getName().equals("title")) continue;
2184 if (el.getName().equals("field")) {
2185 String type = el.getAttribute("type");
2186 if (type != null && type.equals("hidden")) continue;
2187 }
2188
2189 if (el.getName().equals("reported") || el.getName().equals("item")) {
2190 Cell cell = null;
2191
2192 if (reported != null) {
2193 if (reported.size() > position - i) {
2194 Field reportedField = reported.get(position - i);
2195 Element itemField = null;
2196 if (el.getName().equals("item")) {
2197 for (Element subel : el.getChildren()) {
2198 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2199 itemField = subel;
2200 break;
2201 }
2202 }
2203 }
2204 cell = new Cell(reportedField, itemField);
2205 } else {
2206 i += reported.size();
2207 continue;
2208 }
2209 }
2210
2211 if (cell != null) {
2212 items.put(position, cell);
2213 return cell;
2214 }
2215 }
2216
2217 if (i < position) {
2218 i++;
2219 continue;
2220 }
2221
2222 return mkItem(el, position);
2223 }
2224 }
2225 }
2226
2227 return mkItem(responseElement == null ? response : responseElement, position);
2228 }
2229
2230 @Override
2231 public int getItemViewType(int position) {
2232 return getItem(position).viewType;
2233 }
2234
2235 @Override
2236 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2237 switch(viewType) {
2238 case TYPE_ERROR: {
2239 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2240 return new ErrorViewHolder(binding);
2241 }
2242 case TYPE_NOTE: {
2243 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2244 return new NoteViewHolder(binding);
2245 }
2246 case TYPE_WEB: {
2247 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2248 return new WebViewHolder(binding);
2249 }
2250 case TYPE_RESULT_FIELD: {
2251 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2252 return new ResultFieldViewHolder(binding);
2253 }
2254 case TYPE_RESULT_CELL: {
2255 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2256 return new ResultCellViewHolder(binding);
2257 }
2258 case TYPE_CHECKBOX_FIELD: {
2259 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2260 return new CheckboxFieldViewHolder(binding);
2261 }
2262 case TYPE_SEARCH_LIST_FIELD: {
2263 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2264 return new SearchListFieldViewHolder(binding);
2265 }
2266 case TYPE_RADIO_EDIT_FIELD: {
2267 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2268 return new RadioEditFieldViewHolder(binding);
2269 }
2270 case TYPE_SPINNER_FIELD: {
2271 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2272 return new SpinnerFieldViewHolder(binding);
2273 }
2274 case TYPE_TEXT_FIELD: {
2275 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2276 return new TextFieldViewHolder(binding);
2277 }
2278 case TYPE_PROGRESSBAR: {
2279 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2280 return new ProgressBarViewHolder(binding);
2281 }
2282 default:
2283 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2284 }
2285 }
2286
2287 @Override
2288 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2289 viewHolder.bind(getItem(position));
2290 }
2291
2292 public View getView() {
2293 return mBinding.getRoot();
2294 }
2295
2296 public boolean validate() {
2297 int count = getItemCount();
2298 boolean isValid = true;
2299 for (int i = 0; i < count; i++) {
2300 boolean oneIsValid = getItem(i).validate();
2301 isValid = isValid && oneIsValid;
2302 }
2303 notifyDataSetChanged();
2304 return isValid;
2305 }
2306
2307 public boolean execute() {
2308 return execute("execute");
2309 }
2310
2311 public boolean execute(int actionPosition) {
2312 return execute(actionsAdapter.getItem(actionPosition));
2313 }
2314
2315 public boolean execute(String action) {
2316 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2317
2318 if (response == null) return true;
2319 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2320 if (command == null) return true;
2321 String status = command.getAttribute("status");
2322 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2323
2324 if (actionToWebview != null) {
2325 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2326 return false;
2327 }
2328
2329 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2330 packet.setTo(response.getFrom());
2331 final Element c = packet.addChild("command", Namespace.COMMANDS);
2332 c.setAttribute("node", command.getAttribute("node"));
2333 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2334 c.setAttribute("action", action);
2335
2336 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2337 if (!action.equals("cancel") &&
2338 !action.equals("prev") &&
2339 responseElement != null &&
2340 responseElement.getName().equals("x") &&
2341 responseElement.getNamespace().equals("jabber:x:data") &&
2342 formType != null && formType.equals("form")) {
2343
2344 responseElement.setAttribute("type", "submit");
2345 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2346 if (rsm != null) {
2347 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2348 max.setContent("1000");
2349 rsm.addChild(max);
2350 }
2351 c.addChild(responseElement);
2352 }
2353
2354 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2355 getView().post(() -> {
2356 updateWithResponse(iq);
2357 });
2358 });
2359
2360 loading();
2361 return false;
2362 }
2363
2364 protected void loading() {
2365 loadingTimer.schedule(new TimerTask() {
2366 @Override
2367 public void run() {
2368 getView().post(() -> {
2369 loading = true;
2370 notifyDataSetChanged();
2371 });
2372 }
2373 }, 500);
2374 }
2375
2376 protected GridLayoutManager setupLayoutManager() {
2377 layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2378 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2379 @Override
2380 public int getSpanSize(int position) {
2381 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2382 return 1;
2383 }
2384 });
2385 return layoutManager;
2386 }
2387
2388 public void setBinding(CommandPageBinding b) {
2389 mBinding = b;
2390 // https://stackoverflow.com/a/32350474/8611
2391 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2392 @Override
2393 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2394 if(rv.getChildCount() > 0) {
2395 int[] location = new int[2];
2396 rv.getLocationOnScreen(location);
2397 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2398 if (childView instanceof ViewGroup) {
2399 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2400 }
2401 if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2402 int action = e.getAction();
2403 switch (action) {
2404 case MotionEvent.ACTION_DOWN:
2405 rv.requestDisallowInterceptTouchEvent(true);
2406 }
2407 }
2408 }
2409
2410 return false;
2411 }
2412
2413 @Override
2414 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2415
2416 @Override
2417 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2418 });
2419 mBinding.form.setLayoutManager(setupLayoutManager());
2420 mBinding.form.setAdapter(this);
2421 mBinding.actions.setAdapter(actionsAdapter);
2422 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2423 if (execute(pos)) {
2424 removeSession(CommandSession.this);
2425 }
2426 });
2427
2428 actionsAdapter.notifyDataSetChanged();
2429 }
2430
2431 // https://stackoverflow.com/a/36037991/8611
2432 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2433 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2434 View child = viewGroup.getChildAt(i);
2435 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2436 View foundView = findViewAt((ViewGroup) child, x, y);
2437 if (foundView != null && foundView.isShown()) {
2438 return foundView;
2439 }
2440 } else {
2441 int[] location = new int[2];
2442 child.getLocationOnScreen(location);
2443 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2444 if (rect.contains((int)x, (int)y)) {
2445 return child;
2446 }
2447 }
2448 }
2449
2450 return null;
2451 }
2452 }
2453 }
2454}