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 SpannableStringBuilder text = new SpannableStringBuilder(cell.el.findChildContent("value", "jabber:x:data"));
1572 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1573 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1574 }
1575
1576 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1577 binding.text.setText(text);
1578
1579 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1580 method.setOnLinkLongClickListener((tv, url) -> {
1581 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1582 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1583 return true;
1584 });
1585 binding.text.setMovementMethod(method);
1586 }
1587 }
1588 }
1589
1590 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1591 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1592 super(binding);
1593 binding.row.setOnClickListener((v) -> {
1594 binding.checkbox.toggle();
1595 });
1596 binding.checkbox.setOnCheckedChangeListener(this);
1597 }
1598 protected Element mValue = null;
1599
1600 @Override
1601 public void bind(Item item) {
1602 Field field = (Field) item;
1603 binding.label.setText(field.getLabel().or(""));
1604 setTextOrHide(binding.desc, field.getDesc());
1605 mValue = field.getValue();
1606 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1607 }
1608
1609 @Override
1610 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1611 if (mValue == null) return;
1612
1613 mValue.setContent(isChecked ? "true" : "false");
1614 }
1615 }
1616
1617 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1618 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1619 super(binding);
1620 binding.search.addTextChangedListener(this);
1621 }
1622 protected Element mValue = null;
1623 List<Option> options = new ArrayList<>();
1624 protected ArrayAdapter<Option> adapter;
1625 protected boolean open;
1626
1627 @Override
1628 public void bind(Item item) {
1629 Field field = (Field) item;
1630 setTextOrHide(binding.label, field.getLabel());
1631 setTextOrHide(binding.desc, field.getDesc());
1632
1633 if (field.error != null) {
1634 binding.desc.setVisibility(View.VISIBLE);
1635 binding.desc.setText(field.error);
1636 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1637 } else {
1638 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1639 }
1640
1641 mValue = field.getValue();
1642
1643 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1644 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1645 setupInputType(field.el, binding.search, null);
1646
1647 options = field.getOptions();
1648 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1649 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1650 if (open) binding.search.setText(mValue.getContent());
1651 });
1652 search("");
1653 }
1654
1655 @Override
1656 public void afterTextChanged(Editable s) {
1657 if (open) mValue.setContent(s.toString());
1658 search(s.toString());
1659 }
1660
1661 @Override
1662 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1663
1664 @Override
1665 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1666
1667 protected void search(String s) {
1668 List<Option> filteredOptions;
1669 final String q = s.replaceAll("\\W", "").toLowerCase();
1670 if (q == null || q.equals("")) {
1671 filteredOptions = options;
1672 } else {
1673 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1674 }
1675 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1676 binding.list.setAdapter(adapter);
1677
1678 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1679 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1680 }
1681 }
1682
1683 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1684 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1685 super(binding);
1686 binding.open.addTextChangedListener(this);
1687 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1688 @Override
1689 public View getView(int position, View convertView, ViewGroup parent) {
1690 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1691 v.setId(position);
1692 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1693 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1694 return v;
1695 }
1696 };
1697 }
1698 protected Element mValue = null;
1699 protected ArrayAdapter<Option> options;
1700
1701 @Override
1702 public void bind(Item item) {
1703 Field field = (Field) item;
1704 setTextOrHide(binding.label, field.getLabel());
1705 setTextOrHide(binding.desc, field.getDesc());
1706
1707 if (field.error != null) {
1708 binding.desc.setVisibility(View.VISIBLE);
1709 binding.desc.setText(field.error);
1710 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1711 } else {
1712 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1713 }
1714
1715 mValue = field.getValue();
1716
1717 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1718 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1719 binding.open.setText(mValue.getContent());
1720 setupInputType(field.el, binding.open, null);
1721
1722 options.clear();
1723 List<Option> theOptions = field.getOptions();
1724 options.addAll(theOptions);
1725
1726 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1727 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1728 float maxColumnWidth = theOptions.stream().map((x) ->
1729 StaticLayout.getDesiredWidth(x.toString(), paint)
1730 ).max(Float::compare).orElse(new Float(0.0));
1731 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1732 binding.radios.setNumColumns(theOptions.size());
1733 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1734 binding.radios.setNumColumns(theOptions.size() / 2);
1735 } else {
1736 binding.radios.setNumColumns(1);
1737 }
1738 binding.radios.setAdapter(options);
1739 }
1740
1741 @Override
1742 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1743 if (mValue == null) return;
1744
1745 if (isChecked) {
1746 mValue.setContent(options.getItem(radio.getId()).getValue());
1747 binding.open.setText(mValue.getContent());
1748 }
1749 options.notifyDataSetChanged();
1750 }
1751
1752 @Override
1753 public void afterTextChanged(Editable s) {
1754 if (mValue == null) return;
1755
1756 mValue.setContent(s.toString());
1757 options.notifyDataSetChanged();
1758 }
1759
1760 @Override
1761 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1762
1763 @Override
1764 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1765 }
1766
1767 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1768 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1769 super(binding);
1770 binding.spinner.setOnItemSelectedListener(this);
1771 }
1772 protected Element mValue = null;
1773
1774 @Override
1775 public void bind(Item item) {
1776 Field field = (Field) item;
1777 setTextOrHide(binding.label, field.getLabel());
1778 binding.spinner.setPrompt(field.getLabel().or(""));
1779 setTextOrHide(binding.desc, field.getDesc());
1780
1781 mValue = field.getValue();
1782
1783 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1784 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1785 options.addAll(field.getOptions());
1786
1787 binding.spinner.setAdapter(options);
1788 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1789 }
1790
1791 @Override
1792 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1793 Option o = (Option) parent.getItemAtPosition(pos);
1794 if (mValue == null) return;
1795
1796 mValue.setContent(o == null ? "" : o.getValue());
1797 }
1798
1799 @Override
1800 public void onNothingSelected(AdapterView<?> parent) {
1801 mValue.setContent("");
1802 }
1803 }
1804
1805 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1806 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1807 super(binding);
1808 binding.textinput.addTextChangedListener(this);
1809 }
1810 protected Element mValue = null;
1811
1812 @Override
1813 public void bind(Item item) {
1814 Field field = (Field) item;
1815 binding.textinputLayout.setHint(field.getLabel().or(""));
1816
1817 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1818 for (String desc : field.getDesc().asSet()) {
1819 binding.textinputLayout.setHelperText(desc);
1820 }
1821
1822 binding.textinputLayout.setErrorEnabled(field.error != null);
1823 if (field.error != null) binding.textinputLayout.setError(field.error);
1824
1825 mValue = field.getValue();
1826 binding.textinput.setText(mValue.getContent());
1827 setupInputType(field.el, binding.textinput, binding.textinputLayout);
1828 }
1829
1830 @Override
1831 public void afterTextChanged(Editable s) {
1832 if (mValue == null) return;
1833
1834 mValue.setContent(s.toString());
1835 }
1836
1837 @Override
1838 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1839
1840 @Override
1841 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1842 }
1843
1844 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1845 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1846 protected String boundUrl = "";
1847
1848 @Override
1849 public void bind(Item oob) {
1850 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1851 binding.webview.getSettings().setJavaScriptEnabled(true);
1852 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");
1853 binding.webview.getSettings().setDatabaseEnabled(true);
1854 binding.webview.getSettings().setDomStorageEnabled(true);
1855 binding.webview.setWebChromeClient(new WebChromeClient() {
1856 @Override
1857 public void onProgressChanged(WebView view, int newProgress) {
1858 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1859 binding.progressbar.setProgress(newProgress);
1860 }
1861 });
1862 binding.webview.setWebViewClient(new WebViewClient() {
1863 @Override
1864 public void onPageFinished(WebView view, String url) {
1865 super.onPageFinished(view, url);
1866 mTitle = view.getTitle();
1867 ConversationPagerAdapter.this.notifyDataSetChanged();
1868 }
1869 });
1870 final String url = oob.el.findChildContent("url", "jabber:x:oob");
1871 if (!boundUrl.equals(url)) {
1872 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1873 binding.webview.loadUrl(url);
1874 boundUrl = url;
1875 }
1876 }
1877
1878 class JsObject {
1879 @JavascriptInterface
1880 public void execute() { execute("execute"); }
1881
1882 @JavascriptInterface
1883 public void execute(String action) {
1884 getView().post(() -> {
1885 actionToWebview = null;
1886 if(CommandSession.this.execute(action)) {
1887 removeSession(CommandSession.this);
1888 }
1889 });
1890 }
1891
1892 @JavascriptInterface
1893 public void preventDefault() {
1894 actionToWebview = binding.webview;
1895 }
1896 }
1897 }
1898
1899 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1900 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1901
1902 @Override
1903 public void bind(Item item) { }
1904 }
1905
1906 class Item {
1907 protected Element el;
1908 protected int viewType;
1909 protected String error = null;
1910
1911 Item(Element el, int viewType) {
1912 this.el = el;
1913 this.viewType = viewType;
1914 }
1915
1916 public boolean validate() {
1917 error = null;
1918 return true;
1919 }
1920 }
1921
1922 class Field extends Item {
1923 Field(Element el, int viewType) { super(el, viewType); }
1924
1925 @Override
1926 public boolean validate() {
1927 if (!super.validate()) return false;
1928 if (el.findChild("required", "jabber:x:data") == null) return true;
1929 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1930
1931 error = "this value is required";
1932 return false;
1933 }
1934
1935 public String getVar() {
1936 return el.getAttribute("var");
1937 }
1938
1939 public Optional<String> getType() {
1940 return Optional.fromNullable(el.getAttribute("type"));
1941 }
1942
1943 public Optional<String> getLabel() {
1944 String label = el.getAttribute("label");
1945 if (label == null) label = getVar();
1946 return Optional.fromNullable(label);
1947 }
1948
1949 public Optional<String> getDesc() {
1950 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1951 }
1952
1953 public Element getValue() {
1954 Element value = el.findChild("value", "jabber:x:data");
1955 if (value == null) {
1956 value = el.addChild("value", "jabber:x:data");
1957 }
1958 return value;
1959 }
1960
1961 public List<Option> getOptions() {
1962 return Option.forField(el);
1963 }
1964 }
1965
1966 class Cell extends Item {
1967 protected Field reported;
1968
1969 Cell(Field reported, Element item) {
1970 super(item, TYPE_RESULT_CELL);
1971 this.reported = reported;
1972 }
1973 }
1974
1975 protected Field mkField(Element el) {
1976 int viewType = -1;
1977
1978 String formType = responseElement.getAttribute("type");
1979 if (formType != null) {
1980 String fieldType = el.getAttribute("type");
1981 if (fieldType == null) fieldType = "text-single";
1982
1983 if (formType.equals("result") || fieldType.equals("fixed")) {
1984 viewType = TYPE_RESULT_FIELD;
1985 } else if (formType.equals("form")) {
1986 if (fieldType.equals("boolean")) {
1987 viewType = TYPE_CHECKBOX_FIELD;
1988 } else if (fieldType.equals("list-single")) {
1989 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1990 if (Option.forField(el).size() > 9) {
1991 viewType = TYPE_SEARCH_LIST_FIELD;
1992 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1993 viewType = TYPE_RADIO_EDIT_FIELD;
1994 } else {
1995 viewType = TYPE_SPINNER_FIELD;
1996 }
1997 } else {
1998 viewType = TYPE_TEXT_FIELD;
1999 }
2000 }
2001
2002 return new Field(el, viewType);
2003 }
2004
2005 return null;
2006 }
2007
2008 protected Item mkItem(Element el, int pos) {
2009 int viewType = -1;
2010
2011 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2012 if (el.getName().equals("note")) {
2013 viewType = TYPE_NOTE;
2014 } else if (el.getNamespace().equals("jabber:x:oob")) {
2015 viewType = TYPE_WEB;
2016 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2017 viewType = TYPE_NOTE;
2018 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2019 Field field = mkField(el);
2020 if (field != null) {
2021 items.put(pos, field);
2022 return field;
2023 }
2024 }
2025 } else if (response != null) {
2026 viewType = TYPE_ERROR;
2027 }
2028
2029 Item item = new Item(el, viewType);
2030 items.put(pos, item);
2031 return item;
2032 }
2033
2034 final int TYPE_ERROR = 1;
2035 final int TYPE_NOTE = 2;
2036 final int TYPE_WEB = 3;
2037 final int TYPE_RESULT_FIELD = 4;
2038 final int TYPE_TEXT_FIELD = 5;
2039 final int TYPE_CHECKBOX_FIELD = 6;
2040 final int TYPE_SPINNER_FIELD = 7;
2041 final int TYPE_RADIO_EDIT_FIELD = 8;
2042 final int TYPE_RESULT_CELL = 9;
2043 final int TYPE_PROGRESSBAR = 10;
2044 final int TYPE_SEARCH_LIST_FIELD = 11;
2045
2046 protected boolean loading = false;
2047 protected Timer loadingTimer = new Timer();
2048 protected String mTitle;
2049 protected String mNode;
2050 protected CommandPageBinding mBinding = null;
2051 protected IqPacket response = null;
2052 protected Element responseElement = null;
2053 protected List<Field> reported = null;
2054 protected SparseArray<Item> items = new SparseArray<>();
2055 protected XmppConnectionService xmppConnectionService;
2056 protected ArrayAdapter<String> actionsAdapter;
2057 protected GridLayoutManager layoutManager;
2058 protected WebView actionToWebview = null;
2059
2060 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2061 loading();
2062 mTitle = title;
2063 mNode = node;
2064 this.xmppConnectionService = xmppConnectionService;
2065 if (mPager != null) setupLayoutManager();
2066 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2067 @Override
2068 public View getView(int position, View convertView, ViewGroup parent) {
2069 View v = super.getView(position, convertView, parent);
2070 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2071 tv.setGravity(Gravity.CENTER);
2072 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2073 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2074 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2075 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2076 return v;
2077 }
2078 };
2079 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2080 @Override
2081 public void onChanged() {
2082 if (mBinding == null) return;
2083
2084 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2085 }
2086
2087 @Override
2088 public void onInvalidated() {}
2089 });
2090 }
2091
2092 public String getTitle() {
2093 return mTitle;
2094 }
2095
2096 public void updateWithResponse(IqPacket iq) {
2097 this.loadingTimer.cancel();
2098 this.loadingTimer = new Timer();
2099 this.loading = false;
2100 this.responseElement = null;
2101 this.reported = null;
2102 this.response = iq;
2103 this.items.clear();
2104 this.actionsAdapter.clear();
2105 layoutManager.setSpanCount(1);
2106
2107 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2108 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2109 if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2110 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2111 }
2112
2113 for (Element el : command.getChildren()) {
2114 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2115 for (Element action : el.getChildren()) {
2116 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2117 if (action.getName().equals("execute")) continue;
2118
2119 actionsAdapter.add(action.getName());
2120 }
2121 }
2122 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2123 String title = el.findChildContent("title", "jabber:x:data");
2124 if (title != null) {
2125 mTitle = title;
2126 ConversationPagerAdapter.this.notifyDataSetChanged();
2127 }
2128
2129 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2130 this.responseElement = el;
2131 setupReported(el.findChild("reported", "jabber:x:data"));
2132 layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2133 }
2134 break;
2135 }
2136 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2137 String url = el.findChildContent("url", "jabber:x:oob");
2138 if (url != null) {
2139 String scheme = Uri.parse(url).getScheme();
2140 if (scheme.equals("http") || scheme.equals("https")) {
2141 this.responseElement = el;
2142 break;
2143 }
2144 }
2145 }
2146 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2147 this.responseElement = el;
2148 break;
2149 }
2150 }
2151
2152 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2153 removeSession(this);
2154 return;
2155 }
2156
2157 if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2158 // No actions have been given, but we are not done?
2159 // This is probably a spec violation, but we should do *something*
2160 actionsAdapter.add("execute");
2161 }
2162
2163 if (!actionsAdapter.isEmpty()) {
2164 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2165 actionsAdapter.add("close");
2166 } else if (actionsAdapter.getPosition("cancel") < 0) {
2167 actionsAdapter.insert("cancel", 0);
2168 }
2169 }
2170 }
2171
2172 if (actionsAdapter.isEmpty()) {
2173 actionsAdapter.add("close");
2174 }
2175
2176 notifyDataSetChanged();
2177 }
2178
2179 protected void setupReported(Element el) {
2180 if (el == null) {
2181 reported = null;
2182 return;
2183 }
2184
2185 reported = new ArrayList<>();
2186 for (Element fieldEl : el.getChildren()) {
2187 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2188 reported.add(mkField(fieldEl));
2189 }
2190 }
2191
2192 @Override
2193 public int getItemCount() {
2194 if (loading) return 1;
2195 if (response == null) return 0;
2196 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2197 int i = 0;
2198 for (Element el : responseElement.getChildren()) {
2199 if (!el.getNamespace().equals("jabber:x:data")) continue;
2200 if (el.getName().equals("title")) continue;
2201 if (el.getName().equals("field")) {
2202 String type = el.getAttribute("type");
2203 if (type != null && type.equals("hidden")) continue;
2204 }
2205
2206 if (el.getName().equals("reported") || el.getName().equals("item")) {
2207 if (reported != null) i += reported.size();
2208 continue;
2209 }
2210
2211 i++;
2212 }
2213 return i;
2214 }
2215 return 1;
2216 }
2217
2218 public Item getItem(int position) {
2219 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2220 if (items.get(position) != null) return items.get(position);
2221 if (response == null) return null;
2222
2223 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2224 if (responseElement.getNamespace().equals("jabber:x:data")) {
2225 int i = 0;
2226 for (Element el : responseElement.getChildren()) {
2227 if (!el.getNamespace().equals("jabber:x:data")) continue;
2228 if (el.getName().equals("title")) continue;
2229 if (el.getName().equals("field")) {
2230 String type = el.getAttribute("type");
2231 if (type != null && type.equals("hidden")) continue;
2232 }
2233
2234 if (el.getName().equals("reported") || el.getName().equals("item")) {
2235 Cell cell = null;
2236
2237 if (reported != null) {
2238 if (reported.size() > position - i) {
2239 Field reportedField = reported.get(position - i);
2240 Element itemField = null;
2241 if (el.getName().equals("item")) {
2242 for (Element subel : el.getChildren()) {
2243 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2244 itemField = subel;
2245 break;
2246 }
2247 }
2248 }
2249 cell = new Cell(reportedField, itemField);
2250 } else {
2251 i += reported.size();
2252 continue;
2253 }
2254 }
2255
2256 if (cell != null) {
2257 items.put(position, cell);
2258 return cell;
2259 }
2260 }
2261
2262 if (i < position) {
2263 i++;
2264 continue;
2265 }
2266
2267 return mkItem(el, position);
2268 }
2269 }
2270 }
2271
2272 return mkItem(responseElement == null ? response : responseElement, position);
2273 }
2274
2275 @Override
2276 public int getItemViewType(int position) {
2277 return getItem(position).viewType;
2278 }
2279
2280 @Override
2281 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2282 switch(viewType) {
2283 case TYPE_ERROR: {
2284 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2285 return new ErrorViewHolder(binding);
2286 }
2287 case TYPE_NOTE: {
2288 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2289 return new NoteViewHolder(binding);
2290 }
2291 case TYPE_WEB: {
2292 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2293 return new WebViewHolder(binding);
2294 }
2295 case TYPE_RESULT_FIELD: {
2296 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2297 return new ResultFieldViewHolder(binding);
2298 }
2299 case TYPE_RESULT_CELL: {
2300 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2301 return new ResultCellViewHolder(binding);
2302 }
2303 case TYPE_CHECKBOX_FIELD: {
2304 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2305 return new CheckboxFieldViewHolder(binding);
2306 }
2307 case TYPE_SEARCH_LIST_FIELD: {
2308 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2309 return new SearchListFieldViewHolder(binding);
2310 }
2311 case TYPE_RADIO_EDIT_FIELD: {
2312 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2313 return new RadioEditFieldViewHolder(binding);
2314 }
2315 case TYPE_SPINNER_FIELD: {
2316 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2317 return new SpinnerFieldViewHolder(binding);
2318 }
2319 case TYPE_TEXT_FIELD: {
2320 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2321 return new TextFieldViewHolder(binding);
2322 }
2323 case TYPE_PROGRESSBAR: {
2324 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2325 return new ProgressBarViewHolder(binding);
2326 }
2327 default:
2328 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2329 }
2330 }
2331
2332 @Override
2333 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2334 viewHolder.bind(getItem(position));
2335 }
2336
2337 public View getView() {
2338 return mBinding.getRoot();
2339 }
2340
2341 public boolean validate() {
2342 int count = getItemCount();
2343 boolean isValid = true;
2344 for (int i = 0; i < count; i++) {
2345 boolean oneIsValid = getItem(i).validate();
2346 isValid = isValid && oneIsValid;
2347 }
2348 notifyDataSetChanged();
2349 return isValid;
2350 }
2351
2352 public boolean execute() {
2353 return execute("execute");
2354 }
2355
2356 public boolean execute(int actionPosition) {
2357 return execute(actionsAdapter.getItem(actionPosition));
2358 }
2359
2360 public boolean execute(String action) {
2361 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2362
2363 if (response == null) return true;
2364 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2365 if (command == null) return true;
2366 String status = command.getAttribute("status");
2367 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2368
2369 if (actionToWebview != null) {
2370 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2371 return false;
2372 }
2373
2374 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2375 packet.setTo(response.getFrom());
2376 final Element c = packet.addChild("command", Namespace.COMMANDS);
2377 c.setAttribute("node", mNode);
2378 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2379 c.setAttribute("action", action);
2380
2381 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2382 if (!action.equals("cancel") &&
2383 !action.equals("prev") &&
2384 responseElement != null &&
2385 responseElement.getName().equals("x") &&
2386 responseElement.getNamespace().equals("jabber:x:data") &&
2387 formType != null && formType.equals("form")) {
2388
2389 responseElement.setAttribute("type", "submit");
2390 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2391 if (rsm != null) {
2392 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2393 max.setContent("1000");
2394 rsm.addChild(max);
2395 }
2396 c.addChild(responseElement);
2397 }
2398
2399 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2400 getView().post(() -> {
2401 updateWithResponse(iq);
2402 });
2403 });
2404
2405 loading();
2406 return false;
2407 }
2408
2409 protected void loading() {
2410 loadingTimer.schedule(new TimerTask() {
2411 @Override
2412 public void run() {
2413 getView().post(() -> {
2414 loading = true;
2415 notifyDataSetChanged();
2416 });
2417 }
2418 }, 500);
2419 }
2420
2421 protected GridLayoutManager setupLayoutManager() {
2422 layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2423 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2424 @Override
2425 public int getSpanSize(int position) {
2426 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2427 return 1;
2428 }
2429 });
2430 return layoutManager;
2431 }
2432
2433 public void setBinding(CommandPageBinding b) {
2434 mBinding = b;
2435 // https://stackoverflow.com/a/32350474/8611
2436 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2437 @Override
2438 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2439 if(rv.getChildCount() > 0) {
2440 int[] location = new int[2];
2441 rv.getLocationOnScreen(location);
2442 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2443 if (childView instanceof ViewGroup) {
2444 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2445 }
2446 if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2447 int action = e.getAction();
2448 switch (action) {
2449 case MotionEvent.ACTION_DOWN:
2450 rv.requestDisallowInterceptTouchEvent(true);
2451 }
2452 }
2453 }
2454
2455 return false;
2456 }
2457
2458 @Override
2459 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2460
2461 @Override
2462 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2463 });
2464 mBinding.form.setLayoutManager(setupLayoutManager());
2465 mBinding.form.setAdapter(this);
2466 mBinding.actions.setAdapter(actionsAdapter);
2467 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2468 if (execute(pos)) {
2469 removeSession(CommandSession.this);
2470 }
2471 });
2472
2473 actionsAdapter.notifyDataSetChanged();
2474 }
2475
2476 // https://stackoverflow.com/a/36037991/8611
2477 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2478 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2479 View child = viewGroup.getChildAt(i);
2480 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2481 View foundView = findViewAt((ViewGroup) child, x, y);
2482 if (foundView != null && foundView.isShown()) {
2483 return foundView;
2484 }
2485 } else {
2486 int[] location = new int[2];
2487 child.getLocationOnScreen(location);
2488 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2489 if (rect.contains((int)x, (int)y)) {
2490 return child;
2491 }
2492 }
2493 }
2494
2495 return null;
2496 }
2497 }
2498 }
2499}