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