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