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 return message;
695 } else {
696 return this.messages.get(this.messages.size() - 1);
697 }
698 }
699 }
700
701 public @NonNull
702 CharSequence getName() {
703 if (getMode() == MODE_MULTI) {
704 final String roomName = getMucOptions().getName();
705 final String subject = getMucOptions().getSubject();
706 final Bookmark bookmark = getBookmark();
707 final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
708 if (printableValue(roomName)) {
709 return roomName;
710 } else if (printableValue(subject)) {
711 return subject;
712 } else if (printableValue(bookmarkName, false)) {
713 return bookmarkName;
714 } else {
715 final String generatedName = getMucOptions().createNameFromParticipants();
716 if (printableValue(generatedName)) {
717 return generatedName;
718 } else {
719 return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
720 }
721 }
722 } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
723 return contactJid;
724 } else {
725 return this.getContact().getDisplayName();
726 }
727 }
728
729 public String getAccountUuid() {
730 return this.accountUuid;
731 }
732
733 public Account getAccount() {
734 return this.account;
735 }
736
737 public void setAccount(final Account account) {
738 this.account = account;
739 }
740
741 public Contact getContact() {
742 return this.account.getRoster().getContact(this.contactJid);
743 }
744
745 @Override
746 public Jid getJid() {
747 return this.contactJid;
748 }
749
750 public int getStatus() {
751 return this.status;
752 }
753
754 public void setStatus(int status) {
755 this.status = status;
756 }
757
758 public long getCreated() {
759 return this.created;
760 }
761
762 public ContentValues getContentValues() {
763 ContentValues values = new ContentValues();
764 values.put(UUID, uuid);
765 values.put(NAME, name);
766 values.put(CONTACT, contactUuid);
767 values.put(ACCOUNT, accountUuid);
768 values.put(CONTACTJID, contactJid.toString());
769 values.put(CREATED, created);
770 values.put(STATUS, status);
771 values.put(MODE, mode);
772 synchronized (this.attributes) {
773 values.put(ATTRIBUTES, attributes.toString());
774 }
775 return values;
776 }
777
778 public int getMode() {
779 return this.mode;
780 }
781
782 public void setMode(int mode) {
783 this.mode = mode;
784 }
785
786 /**
787 * short for is Private and Non-anonymous
788 */
789 public boolean isSingleOrPrivateAndNonAnonymous() {
790 return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
791 }
792
793 public boolean isPrivateAndNonAnonymous() {
794 return getMucOptions().isPrivateAndNonAnonymous();
795 }
796
797 public synchronized MucOptions getMucOptions() {
798 if (this.mucOptions == null) {
799 this.mucOptions = new MucOptions(this);
800 }
801 return this.mucOptions;
802 }
803
804 public void resetMucOptions() {
805 this.mucOptions = null;
806 }
807
808 public void setContactJid(final Jid jid) {
809 this.contactJid = jid;
810 }
811
812 public Jid getNextCounterpart() {
813 return this.nextCounterpart;
814 }
815
816 public void setNextCounterpart(Jid jid) {
817 this.nextCounterpart = jid;
818 }
819
820 public int getNextEncryption() {
821 if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
822 return Message.ENCRYPTION_NONE;
823 }
824 if (OmemoSetting.isAlways()) {
825 return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
826 }
827 final int defaultEncryption;
828 if (suitableForOmemoByDefault(this)) {
829 defaultEncryption = OmemoSetting.getEncryption();
830 } else {
831 defaultEncryption = Message.ENCRYPTION_NONE;
832 }
833 int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
834 if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
835 return defaultEncryption;
836 } else {
837 return encryption;
838 }
839 }
840
841 public boolean setNextEncryption(int encryption) {
842 return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
843 }
844
845 public String getNextMessage() {
846 final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
847 return nextMessage == null ? "" : nextMessage;
848 }
849
850 public @Nullable
851 Draft getDraft() {
852 long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
853 if (timestamp > getLatestMessage().getTimeSent()) {
854 String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
855 if (!TextUtils.isEmpty(message) && timestamp != 0) {
856 return new Draft(message, timestamp);
857 }
858 }
859 return null;
860 }
861
862 public boolean setNextMessage(final String input) {
863 final String message = input == null || input.trim().isEmpty() ? null : input;
864 boolean changed = !getNextMessage().equals(message);
865 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
866 if (changed) {
867 this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
868 }
869 return changed;
870 }
871
872 public Bookmark getBookmark() {
873 return this.account.getBookmark(this.contactJid);
874 }
875
876 public Message findDuplicateMessage(Message message) {
877 synchronized (this.messages) {
878 for (int i = this.messages.size() - 1; i >= 0; --i) {
879 if (this.messages.get(i).similar(message)) {
880 return this.messages.get(i);
881 }
882 }
883 }
884 return null;
885 }
886
887 public boolean hasDuplicateMessage(Message message) {
888 return findDuplicateMessage(message) != null;
889 }
890
891 public Message findSentMessageWithBody(String body) {
892 synchronized (this.messages) {
893 for (int i = this.messages.size() - 1; i >= 0; --i) {
894 Message message = this.messages.get(i);
895 if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
896 String otherBody;
897 if (message.hasFileOnRemoteHost()) {
898 otherBody = message.getFileParams().url;
899 } else {
900 otherBody = message.body;
901 }
902 if (otherBody != null && otherBody.equals(body)) {
903 return message;
904 }
905 }
906 }
907 return null;
908 }
909 }
910
911 public Message findRtpSession(final String sessionId, final int s) {
912 synchronized (this.messages) {
913 for (int i = this.messages.size() - 1; i >= 0; --i) {
914 final Message message = this.messages.get(i);
915 if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
916 return message;
917 }
918 }
919 }
920 return null;
921 }
922
923 public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
924 if (serverMsgId == null || remoteMsgId == null) {
925 return false;
926 }
927 synchronized (this.messages) {
928 for (Message message : this.messages) {
929 if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
930 return true;
931 }
932 }
933 }
934 return false;
935 }
936
937 public MamReference getLastMessageTransmitted() {
938 final MamReference lastClear = getLastClearHistory();
939 MamReference lastReceived = new MamReference(0);
940 synchronized (this.messages) {
941 for (int i = this.messages.size() - 1; i >= 0; --i) {
942 final Message message = this.messages.get(i);
943 if (message.isPrivateMessage()) {
944 continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
945 }
946 if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
947 lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
948 break;
949 }
950 }
951 }
952 return MamReference.max(lastClear, lastReceived);
953 }
954
955 public void setMutedTill(long value) {
956 this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
957 }
958
959 public boolean isMuted() {
960 return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
961 }
962
963 public boolean alwaysNotify() {
964 return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
965 }
966
967 public boolean setAttribute(String key, boolean value) {
968 return setAttribute(key, String.valueOf(value));
969 }
970
971 private boolean setAttribute(String key, long value) {
972 return setAttribute(key, Long.toString(value));
973 }
974
975 private boolean setAttribute(String key, int value) {
976 return setAttribute(key, String.valueOf(value));
977 }
978
979 public boolean setAttribute(String key, String value) {
980 synchronized (this.attributes) {
981 try {
982 if (value == null) {
983 if (this.attributes.has(key)) {
984 this.attributes.remove(key);
985 return true;
986 } else {
987 return false;
988 }
989 } else {
990 final String prev = this.attributes.optString(key, null);
991 this.attributes.put(key, value);
992 return !value.equals(prev);
993 }
994 } catch (JSONException e) {
995 throw new AssertionError(e);
996 }
997 }
998 }
999
1000 public boolean setAttribute(String key, List<Jid> jids) {
1001 JSONArray array = new JSONArray();
1002 for (Jid jid : jids) {
1003 array.put(jid.asBareJid().toString());
1004 }
1005 synchronized (this.attributes) {
1006 try {
1007 this.attributes.put(key, array);
1008 return true;
1009 } catch (JSONException e) {
1010 return false;
1011 }
1012 }
1013 }
1014
1015 public String getAttribute(String key) {
1016 synchronized (this.attributes) {
1017 return this.attributes.optString(key, null);
1018 }
1019 }
1020
1021 private List<Jid> getJidListAttribute(String key) {
1022 ArrayList<Jid> list = new ArrayList<>();
1023 synchronized (this.attributes) {
1024 try {
1025 JSONArray array = this.attributes.getJSONArray(key);
1026 for (int i = 0; i < array.length(); ++i) {
1027 try {
1028 list.add(Jid.of(array.getString(i)));
1029 } catch (IllegalArgumentException e) {
1030 //ignored
1031 }
1032 }
1033 } catch (JSONException e) {
1034 //ignored
1035 }
1036 }
1037 return list;
1038 }
1039
1040 private int getIntAttribute(String key, int defaultValue) {
1041 String value = this.getAttribute(key);
1042 if (value == null) {
1043 return defaultValue;
1044 } else {
1045 try {
1046 return Integer.parseInt(value);
1047 } catch (NumberFormatException e) {
1048 return defaultValue;
1049 }
1050 }
1051 }
1052
1053 public long getLongAttribute(String key, long defaultValue) {
1054 String value = this.getAttribute(key);
1055 if (value == null) {
1056 return defaultValue;
1057 } else {
1058 try {
1059 return Long.parseLong(value);
1060 } catch (NumberFormatException e) {
1061 return defaultValue;
1062 }
1063 }
1064 }
1065
1066 public boolean getBooleanAttribute(String key, boolean defaultValue) {
1067 String value = this.getAttribute(key);
1068 if (value == null) {
1069 return defaultValue;
1070 } else {
1071 return Boolean.parseBoolean(value);
1072 }
1073 }
1074
1075 public void add(Message message) {
1076 synchronized (this.messages) {
1077 this.messages.add(message);
1078 }
1079 }
1080
1081 public void prepend(int offset, Message message) {
1082 synchronized (this.messages) {
1083 this.messages.add(Math.min(offset, this.messages.size()), message);
1084 }
1085 }
1086
1087 public void addAll(int index, List<Message> messages) {
1088 synchronized (this.messages) {
1089 this.messages.addAll(index, messages);
1090 }
1091 account.getPgpDecryptionService().decrypt(messages);
1092 }
1093
1094 public void expireOldMessages(long timestamp) {
1095 synchronized (this.messages) {
1096 for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1097 if (iterator.next().getTimeSent() < timestamp) {
1098 iterator.remove();
1099 }
1100 }
1101 untieMessages();
1102 }
1103 }
1104
1105 public void sort() {
1106 synchronized (this.messages) {
1107 Collections.sort(this.messages, (left, right) -> {
1108 if (left.getTimeSent() < right.getTimeSent()) {
1109 return -1;
1110 } else if (left.getTimeSent() > right.getTimeSent()) {
1111 return 1;
1112 } else {
1113 return 0;
1114 }
1115 });
1116 untieMessages();
1117 }
1118 }
1119
1120 private void untieMessages() {
1121 for (Message message : this.messages) {
1122 message.untie();
1123 }
1124 }
1125
1126 public int unreadCount() {
1127 synchronized (this.messages) {
1128 int count = 0;
1129 for(final Message message : Lists.reverse(this.messages)) {
1130 if (message.isRead()) {
1131 if (message.getType() == Message.TYPE_RTP_SESSION) {
1132 continue;
1133 }
1134 return count;
1135 }
1136 ++count;
1137 }
1138 return count;
1139 }
1140 }
1141
1142 public int receivedMessagesCount() {
1143 int count = 0;
1144 synchronized (this.messages) {
1145 for (Message message : messages) {
1146 if (message.getStatus() == Message.STATUS_RECEIVED) {
1147 ++count;
1148 }
1149 }
1150 }
1151 return count;
1152 }
1153
1154 public int sentMessagesCount() {
1155 int count = 0;
1156 synchronized (this.messages) {
1157 for (Message message : messages) {
1158 if (message.getStatus() != Message.STATUS_RECEIVED) {
1159 ++count;
1160 }
1161 }
1162 }
1163 return count;
1164 }
1165
1166 public boolean canInferPresence() {
1167 final Contact contact = getContact();
1168 if (contact != null && contact.canInferPresence()) return true;
1169 return sentMessagesCount() > 0;
1170 }
1171
1172 public boolean isWithStranger() {
1173 final Contact contact = getContact();
1174 return mode == MODE_SINGLE
1175 && !contact.isOwnServer()
1176 && !contact.showInContactList()
1177 && !contact.isSelf()
1178 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1179 && sentMessagesCount() == 0;
1180 }
1181
1182 public int getReceivedMessagesCountSinceUuid(String uuid) {
1183 if (uuid == null) {
1184 return 0;
1185 }
1186 int count = 0;
1187 synchronized (this.messages) {
1188 for (int i = messages.size() - 1; i >= 0; i--) {
1189 final Message message = messages.get(i);
1190 if (uuid.equals(message.getUuid())) {
1191 return count;
1192 }
1193 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1194 ++count;
1195 }
1196 }
1197 }
1198 return 0;
1199 }
1200
1201 @Override
1202 public int getAvatarBackgroundColor() {
1203 return UIHelper.getColorForName(getName().toString());
1204 }
1205
1206 @Override
1207 public String getAvatarName() {
1208 return getName().toString();
1209 }
1210
1211 public void setCurrentTab(int tab) {
1212 mCurrentTab = tab;
1213 }
1214
1215 public int getCurrentTab() {
1216 if (mCurrentTab >= 0) return mCurrentTab;
1217
1218 if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1219 return 0;
1220 }
1221
1222 return 1;
1223 }
1224
1225 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1226 pagerAdapter.startCommand(command, xmppConnectionService);
1227 }
1228
1229 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1230 pagerAdapter.setupViewPager(pager, tabs);
1231 }
1232
1233 public void showViewPager() {
1234 pagerAdapter.show();
1235 }
1236
1237 public void hideViewPager() {
1238 pagerAdapter.hide();
1239 }
1240
1241 public interface OnMessageFound {
1242 void onMessageFound(final Message message);
1243 }
1244
1245 public static class Draft {
1246 private final String message;
1247 private final long timestamp;
1248
1249 private Draft(String message, long timestamp) {
1250 this.message = message;
1251 this.timestamp = timestamp;
1252 }
1253
1254 public long getTimestamp() {
1255 return timestamp;
1256 }
1257
1258 public String getMessage() {
1259 return message;
1260 }
1261 }
1262
1263 public class ConversationPagerAdapter extends PagerAdapter {
1264 protected ViewPager mPager = null;
1265 protected TabLayout mTabs = null;
1266 ArrayList<CommandSession> sessions = null;
1267 protected View page1 = null;
1268 protected View page2 = null;
1269
1270 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1271 mPager = pager;
1272 mTabs = tabs;
1273
1274 if (mPager == null) return;
1275 if (sessions != null) show();
1276
1277 page1 = pager.getChildAt(0) == null ? page1 : pager.getChildAt(0);
1278 page2 = pager.getChildAt(1) == null ? page2 : pager.getChildAt(1);
1279 pager.setAdapter(this);
1280 tabs.setupWithViewPager(mPager);
1281 pager.post(() -> pager.setCurrentItem(getCurrentTab()));
1282
1283 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1284 public void onPageScrollStateChanged(int state) { }
1285 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1286
1287 public void onPageSelected(int position) {
1288 setCurrentTab(position);
1289 }
1290 });
1291 }
1292
1293 public void show() {
1294 if (sessions == null) {
1295 sessions = new ArrayList<>();
1296 notifyDataSetChanged();
1297 }
1298 if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1299 }
1300
1301 public void hide() {
1302 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1303 if (mPager != null) mPager.setCurrentItem(0);
1304 if (mTabs != null) mTabs.setVisibility(View.GONE);
1305 sessions = null;
1306 notifyDataSetChanged();
1307 }
1308
1309 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1310 show();
1311 CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1312
1313 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1314 packet.setTo(command.getAttributeAsJid("jid"));
1315 final Element c = packet.addChild("command", Namespace.COMMANDS);
1316 c.setAttribute("node", command.getAttribute("node"));
1317 c.setAttribute("action", "execute");
1318 View v = mPager;
1319 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1320 v.post(() -> {
1321 session.updateWithResponse(iq);
1322 });
1323 });
1324
1325 sessions.add(session);
1326 notifyDataSetChanged();
1327 if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1328 }
1329
1330 public void removeSession(CommandSession session) {
1331 sessions.remove(session);
1332 notifyDataSetChanged();
1333 }
1334
1335 @NonNull
1336 @Override
1337 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1338 if (position == 0) {
1339 if (page1.getParent() == null) container.addView(page1);
1340 return page1;
1341 }
1342 if (position == 1) {
1343 if (page2.getParent() == null) container.addView(page2);
1344 return page2;
1345 }
1346
1347 CommandSession session = sessions.get(position-2);
1348 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1349 container.addView(binding.getRoot());
1350 session.setBinding(binding);
1351 return session;
1352 }
1353
1354 @Override
1355 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1356 if (position < 2) return;
1357
1358 container.removeView(((CommandSession) o).getView());
1359 }
1360
1361 @Override
1362 public int getItemPosition(Object o) {
1363 if (mPager != null) {
1364 if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1365 if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1366 }
1367
1368 int pos = sessions == null ? -1 : sessions.indexOf(o);
1369 if (pos < 0) return PagerAdapter.POSITION_NONE;
1370 return pos + 2;
1371 }
1372
1373 @Override
1374 public int getCount() {
1375 if (sessions == null) return 1;
1376
1377 int count = 2 + sessions.size();
1378 if (mTabs == null) return count;
1379
1380 if (count > 2) {
1381 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1382 } else {
1383 mTabs.setTabMode(TabLayout.MODE_FIXED);
1384 }
1385 return count;
1386 }
1387
1388 @Override
1389 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1390 if (view == o) return true;
1391
1392 if (o instanceof CommandSession) {
1393 return ((CommandSession) o).getView() == view;
1394 }
1395
1396 return false;
1397 }
1398
1399 @Nullable
1400 @Override
1401 public CharSequence getPageTitle(int position) {
1402 switch (position) {
1403 case 0:
1404 return "Conversation";
1405 case 1:
1406 return "Commands";
1407 default:
1408 CommandSession session = sessions.get(position-2);
1409 if (session == null) return super.getPageTitle(position);
1410 return session.getTitle();
1411 }
1412 }
1413
1414 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1415 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1416 protected T binding;
1417
1418 public ViewHolder(T binding) {
1419 super(binding.getRoot());
1420 this.binding = binding;
1421 }
1422
1423 abstract public void bind(Item el);
1424
1425 protected void setTextOrHide(TextView v, Optional<String> s) {
1426 if (s == null || !s.isPresent()) {
1427 v.setVisibility(View.GONE);
1428 } else {
1429 v.setVisibility(View.VISIBLE);
1430 v.setText(s.get());
1431 }
1432 }
1433
1434 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1435 int flags = 0;
1436 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1437 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1438
1439 String type = field.getAttribute("type");
1440 if (type != null) {
1441 if (type.equals("text-multi") || type.equals("jid-multi")) {
1442 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1443 }
1444
1445 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1446
1447 if (type.equals("jid-single") || type.equals("jid-multi")) {
1448 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1449 }
1450
1451 if (type.equals("text-private")) {
1452 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1453 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1454 }
1455 }
1456
1457 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1458 if (validate == null) return;
1459 String datatype = validate.getAttribute("datatype");
1460 if (datatype == null) return;
1461
1462 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1463 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1464 }
1465
1466 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1467 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1468 }
1469
1470 if (datatype.equals("xs:date")) {
1471 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1472 }
1473
1474 if (datatype.equals("xs:dateTime")) {
1475 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1476 }
1477
1478 if (datatype.equals("xs:time")) {
1479 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1480 }
1481
1482 if (datatype.equals("xs:anyURI")) {
1483 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1484 }
1485
1486 if (datatype.equals("html:tel")) {
1487 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1488 }
1489
1490 if (datatype.equals("html:email")) {
1491 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1492 }
1493 }
1494 }
1495
1496 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1497 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1498
1499 @Override
1500 public void bind(Item iq) {
1501 binding.errorIcon.setVisibility(View.VISIBLE);
1502
1503 Element error = iq.el.findChild("error");
1504 if (error == null) return;
1505 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1506 if (text == null || text.equals("")) {
1507 text = error.getChildren().get(0).getName();
1508 }
1509 binding.message.setText(text);
1510 }
1511 }
1512
1513 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1514 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1515
1516 @Override
1517 public void bind(Item note) {
1518 binding.message.setText(note.el.getContent());
1519
1520 String type = note.el.getAttribute("type");
1521 if (type != null && type.equals("error")) {
1522 binding.errorIcon.setVisibility(View.VISIBLE);
1523 }
1524 }
1525 }
1526
1527 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1528 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1529
1530 @Override
1531 public void bind(Item item) {
1532 Field field = (Field) item;
1533 setTextOrHide(binding.label, field.getLabel());
1534 setTextOrHide(binding.desc, field.getDesc());
1535
1536 ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1537 for (Element el : field.el.getChildren()) {
1538 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1539 values.add(el.getContent());
1540 }
1541 }
1542 binding.values.setAdapter(values);
1543
1544 if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1545 binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1546 new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1547 });
1548 }
1549
1550 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1551 if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1552 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1553 }
1554 return true;
1555 });
1556 }
1557 }
1558
1559 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1560 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1561
1562 @Override
1563 public void bind(Item item) {
1564 Cell cell = (Cell) item;
1565
1566 if (cell.el == null) {
1567 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1568 setTextOrHide(binding.text, cell.reported.getLabel());
1569 } else {
1570 SpannableStringBuilder text = new SpannableStringBuilder(cell.el.findChildContent("value", "jabber:x:data"));
1571 if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1572 text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1573 }
1574
1575 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1576 binding.text.setText(text);
1577
1578 BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1579 method.setOnLinkLongClickListener((tv, url) -> {
1580 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1581 ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1582 return true;
1583 });
1584 binding.text.setMovementMethod(method);
1585 }
1586 }
1587 }
1588
1589 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1590 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1591 super(binding);
1592 binding.row.setOnClickListener((v) -> {
1593 binding.checkbox.toggle();
1594 });
1595 binding.checkbox.setOnCheckedChangeListener(this);
1596 }
1597 protected Element mValue = null;
1598
1599 @Override
1600 public void bind(Item item) {
1601 Field field = (Field) item;
1602 binding.label.setText(field.getLabel().or(""));
1603 setTextOrHide(binding.desc, field.getDesc());
1604 mValue = field.getValue();
1605 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1606 }
1607
1608 @Override
1609 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1610 if (mValue == null) return;
1611
1612 mValue.setContent(isChecked ? "true" : "false");
1613 }
1614 }
1615
1616 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1617 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1618 super(binding);
1619 binding.search.addTextChangedListener(this);
1620 }
1621 protected Element mValue = null;
1622 List<Option> options = new ArrayList<>();
1623 protected ArrayAdapter<Option> adapter;
1624 protected boolean open;
1625
1626 @Override
1627 public void bind(Item item) {
1628 Field field = (Field) item;
1629 setTextOrHide(binding.label, field.getLabel());
1630 setTextOrHide(binding.desc, field.getDesc());
1631
1632 if (field.error != null) {
1633 binding.desc.setVisibility(View.VISIBLE);
1634 binding.desc.setText(field.error);
1635 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1636 } else {
1637 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1638 }
1639
1640 mValue = field.getValue();
1641
1642 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1643 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1644 setupInputType(field.el, binding.search, null);
1645
1646 options = field.getOptions();
1647 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1648 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1649 if (open) binding.search.setText(mValue.getContent());
1650 });
1651 search("");
1652 }
1653
1654 @Override
1655 public void afterTextChanged(Editable s) {
1656 if (open) mValue.setContent(s.toString());
1657 search(s.toString());
1658 }
1659
1660 @Override
1661 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1662
1663 @Override
1664 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1665
1666 protected void search(String s) {
1667 List<Option> filteredOptions;
1668 final String q = s.replaceAll("\\W", "").toLowerCase();
1669 if (q == null || q.equals("")) {
1670 filteredOptions = options;
1671 } else {
1672 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1673 }
1674 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1675 binding.list.setAdapter(adapter);
1676
1677 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1678 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1679 }
1680 }
1681
1682 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1683 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1684 super(binding);
1685 binding.open.addTextChangedListener(this);
1686 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1687 @Override
1688 public View getView(int position, View convertView, ViewGroup parent) {
1689 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1690 v.setId(position);
1691 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1692 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1693 return v;
1694 }
1695 };
1696 }
1697 protected Element mValue = null;
1698 protected ArrayAdapter<Option> options;
1699
1700 @Override
1701 public void bind(Item item) {
1702 Field field = (Field) item;
1703 setTextOrHide(binding.label, field.getLabel());
1704 setTextOrHide(binding.desc, field.getDesc());
1705
1706 if (field.error != null) {
1707 binding.desc.setVisibility(View.VISIBLE);
1708 binding.desc.setText(field.error);
1709 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1710 } else {
1711 binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1712 }
1713
1714 mValue = field.getValue();
1715
1716 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1717 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1718 binding.open.setText(mValue.getContent());
1719 setupInputType(field.el, binding.open, null);
1720
1721 options.clear();
1722 List<Option> theOptions = field.getOptions();
1723 options.addAll(theOptions);
1724
1725 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1726 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1727 float maxColumnWidth = theOptions.stream().map((x) ->
1728 StaticLayout.getDesiredWidth(x.toString(), paint)
1729 ).max(Float::compare).orElse(new Float(0.0));
1730 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1731 binding.radios.setNumColumns(theOptions.size());
1732 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1733 binding.radios.setNumColumns(theOptions.size() / 2);
1734 } else {
1735 binding.radios.setNumColumns(1);
1736 }
1737 binding.radios.setAdapter(options);
1738 }
1739
1740 @Override
1741 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1742 if (mValue == null) return;
1743
1744 if (isChecked) {
1745 mValue.setContent(options.getItem(radio.getId()).getValue());
1746 binding.open.setText(mValue.getContent());
1747 }
1748 options.notifyDataSetChanged();
1749 }
1750
1751 @Override
1752 public void afterTextChanged(Editable s) {
1753 if (mValue == null) return;
1754
1755 mValue.setContent(s.toString());
1756 options.notifyDataSetChanged();
1757 }
1758
1759 @Override
1760 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1761
1762 @Override
1763 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1764 }
1765
1766 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1767 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1768 super(binding);
1769 binding.spinner.setOnItemSelectedListener(this);
1770 }
1771 protected Element mValue = null;
1772
1773 @Override
1774 public void bind(Item item) {
1775 Field field = (Field) item;
1776 setTextOrHide(binding.label, field.getLabel());
1777 binding.spinner.setPrompt(field.getLabel().or(""));
1778 setTextOrHide(binding.desc, field.getDesc());
1779
1780 mValue = field.getValue();
1781
1782 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1783 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1784 options.addAll(field.getOptions());
1785
1786 binding.spinner.setAdapter(options);
1787 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1788 }
1789
1790 @Override
1791 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1792 Option o = (Option) parent.getItemAtPosition(pos);
1793 if (mValue == null) return;
1794
1795 mValue.setContent(o == null ? "" : o.getValue());
1796 }
1797
1798 @Override
1799 public void onNothingSelected(AdapterView<?> parent) {
1800 mValue.setContent("");
1801 }
1802 }
1803
1804 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1805 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1806 super(binding);
1807 binding.textinput.addTextChangedListener(this);
1808 }
1809 protected Element mValue = null;
1810
1811 @Override
1812 public void bind(Item item) {
1813 Field field = (Field) item;
1814 binding.textinputLayout.setHint(field.getLabel().or(""));
1815
1816 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1817 for (String desc : field.getDesc().asSet()) {
1818 binding.textinputLayout.setHelperText(desc);
1819 }
1820
1821 binding.textinputLayout.setErrorEnabled(field.error != null);
1822 if (field.error != null) binding.textinputLayout.setError(field.error);
1823
1824 mValue = field.getValue();
1825 binding.textinput.setText(mValue.getContent());
1826 setupInputType(field.el, binding.textinput, binding.textinputLayout);
1827 }
1828
1829 @Override
1830 public void afterTextChanged(Editable s) {
1831 if (mValue == null) return;
1832
1833 mValue.setContent(s.toString());
1834 }
1835
1836 @Override
1837 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1838
1839 @Override
1840 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1841 }
1842
1843 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1844 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1845 protected String boundUrl = "";
1846
1847 @Override
1848 public void bind(Item oob) {
1849 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1850 binding.webview.getSettings().setJavaScriptEnabled(true);
1851 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");
1852 binding.webview.getSettings().setDatabaseEnabled(true);
1853 binding.webview.getSettings().setDomStorageEnabled(true);
1854 binding.webview.setWebChromeClient(new WebChromeClient() {
1855 @Override
1856 public void onProgressChanged(WebView view, int newProgress) {
1857 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1858 binding.progressbar.setProgress(newProgress);
1859 }
1860 });
1861 binding.webview.setWebViewClient(new WebViewClient() {
1862 @Override
1863 public void onPageFinished(WebView view, String url) {
1864 super.onPageFinished(view, url);
1865 mTitle = view.getTitle();
1866 ConversationPagerAdapter.this.notifyDataSetChanged();
1867 }
1868 });
1869 final String url = oob.el.findChildContent("url", "jabber:x:oob");
1870 if (!boundUrl.equals(url)) {
1871 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1872 binding.webview.loadUrl(url);
1873 boundUrl = url;
1874 }
1875 }
1876
1877 class JsObject {
1878 @JavascriptInterface
1879 public void execute() { execute("execute"); }
1880
1881 @JavascriptInterface
1882 public void execute(String action) {
1883 getView().post(() -> {
1884 actionToWebview = null;
1885 if(CommandSession.this.execute(action)) {
1886 removeSession(CommandSession.this);
1887 }
1888 });
1889 }
1890
1891 @JavascriptInterface
1892 public void preventDefault() {
1893 actionToWebview = binding.webview;
1894 }
1895 }
1896 }
1897
1898 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1899 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1900
1901 @Override
1902 public void bind(Item item) { }
1903 }
1904
1905 class Item {
1906 protected Element el;
1907 protected int viewType;
1908 protected String error = null;
1909
1910 Item(Element el, int viewType) {
1911 this.el = el;
1912 this.viewType = viewType;
1913 }
1914
1915 public boolean validate() {
1916 error = null;
1917 return true;
1918 }
1919 }
1920
1921 class Field extends Item {
1922 Field(Element el, int viewType) { super(el, viewType); }
1923
1924 @Override
1925 public boolean validate() {
1926 if (!super.validate()) return false;
1927 if (el.findChild("required", "jabber:x:data") == null) return true;
1928 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1929
1930 error = "this value is required";
1931 return false;
1932 }
1933
1934 public String getVar() {
1935 return el.getAttribute("var");
1936 }
1937
1938 public Optional<String> getType() {
1939 return Optional.fromNullable(el.getAttribute("type"));
1940 }
1941
1942 public Optional<String> getLabel() {
1943 String label = el.getAttribute("label");
1944 if (label == null) label = getVar();
1945 return Optional.fromNullable(label);
1946 }
1947
1948 public Optional<String> getDesc() {
1949 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1950 }
1951
1952 public Element getValue() {
1953 Element value = el.findChild("value", "jabber:x:data");
1954 if (value == null) {
1955 value = el.addChild("value", "jabber:x:data");
1956 }
1957 return value;
1958 }
1959
1960 public List<Option> getOptions() {
1961 return Option.forField(el);
1962 }
1963 }
1964
1965 class Cell extends Item {
1966 protected Field reported;
1967
1968 Cell(Field reported, Element item) {
1969 super(item, TYPE_RESULT_CELL);
1970 this.reported = reported;
1971 }
1972 }
1973
1974 protected Field mkField(Element el) {
1975 int viewType = -1;
1976
1977 String formType = responseElement.getAttribute("type");
1978 if (formType != null) {
1979 String fieldType = el.getAttribute("type");
1980 if (fieldType == null) fieldType = "text-single";
1981
1982 if (formType.equals("result") || fieldType.equals("fixed")) {
1983 viewType = TYPE_RESULT_FIELD;
1984 } else if (formType.equals("form")) {
1985 if (fieldType.equals("boolean")) {
1986 viewType = TYPE_CHECKBOX_FIELD;
1987 } else if (fieldType.equals("list-single")) {
1988 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1989 if (Option.forField(el).size() > 9) {
1990 viewType = TYPE_SEARCH_LIST_FIELD;
1991 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1992 viewType = TYPE_RADIO_EDIT_FIELD;
1993 } else {
1994 viewType = TYPE_SPINNER_FIELD;
1995 }
1996 } else {
1997 viewType = TYPE_TEXT_FIELD;
1998 }
1999 }
2000
2001 return new Field(el, viewType);
2002 }
2003
2004 return null;
2005 }
2006
2007 protected Item mkItem(Element el, int pos) {
2008 int viewType = -1;
2009
2010 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2011 if (el.getName().equals("note")) {
2012 viewType = TYPE_NOTE;
2013 } else if (el.getNamespace().equals("jabber:x:oob")) {
2014 viewType = TYPE_WEB;
2015 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2016 viewType = TYPE_NOTE;
2017 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2018 Field field = mkField(el);
2019 if (field != null) {
2020 items.put(pos, field);
2021 return field;
2022 }
2023 }
2024 } else if (response != null) {
2025 viewType = TYPE_ERROR;
2026 }
2027
2028 Item item = new Item(el, viewType);
2029 items.put(pos, item);
2030 return item;
2031 }
2032
2033 final int TYPE_ERROR = 1;
2034 final int TYPE_NOTE = 2;
2035 final int TYPE_WEB = 3;
2036 final int TYPE_RESULT_FIELD = 4;
2037 final int TYPE_TEXT_FIELD = 5;
2038 final int TYPE_CHECKBOX_FIELD = 6;
2039 final int TYPE_SPINNER_FIELD = 7;
2040 final int TYPE_RADIO_EDIT_FIELD = 8;
2041 final int TYPE_RESULT_CELL = 9;
2042 final int TYPE_PROGRESSBAR = 10;
2043 final int TYPE_SEARCH_LIST_FIELD = 11;
2044
2045 protected boolean loading = false;
2046 protected Timer loadingTimer = new Timer();
2047 protected String mTitle;
2048 protected CommandPageBinding mBinding = null;
2049 protected IqPacket response = null;
2050 protected Element responseElement = null;
2051 protected List<Field> reported = null;
2052 protected SparseArray<Item> items = new SparseArray<>();
2053 protected XmppConnectionService xmppConnectionService;
2054 protected ArrayAdapter<String> actionsAdapter;
2055 protected GridLayoutManager layoutManager;
2056 protected WebView actionToWebview = null;
2057
2058 CommandSession(String title, XmppConnectionService xmppConnectionService) {
2059 loading();
2060 mTitle = title;
2061 this.xmppConnectionService = xmppConnectionService;
2062 if (mPager != null) setupLayoutManager();
2063 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2064 @Override
2065 public View getView(int position, View convertView, ViewGroup parent) {
2066 View v = super.getView(position, convertView, parent);
2067 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2068 tv.setGravity(Gravity.CENTER);
2069 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2070 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2071 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2072 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2073 return v;
2074 }
2075 };
2076 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2077 @Override
2078 public void onChanged() {
2079 if (mBinding == null) return;
2080
2081 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2082 }
2083
2084 @Override
2085 public void onInvalidated() {}
2086 });
2087 }
2088
2089 public String getTitle() {
2090 return mTitle;
2091 }
2092
2093 public void updateWithResponse(IqPacket iq) {
2094 this.loadingTimer.cancel();
2095 this.loadingTimer = new Timer();
2096 this.loading = false;
2097 this.responseElement = null;
2098 this.reported = null;
2099 this.response = iq;
2100 this.items.clear();
2101 this.actionsAdapter.clear();
2102 layoutManager.setSpanCount(1);
2103
2104 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2105 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2106 for (Element el : command.getChildren()) {
2107 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2108 for (Element action : el.getChildren()) {
2109 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2110 if (action.getName().equals("execute")) continue;
2111
2112 actionsAdapter.add(action.getName());
2113 }
2114 }
2115 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2116 String title = el.findChildContent("title", "jabber:x:data");
2117 if (title != null) {
2118 mTitle = title;
2119 ConversationPagerAdapter.this.notifyDataSetChanged();
2120 }
2121
2122 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2123 this.responseElement = el;
2124 setupReported(el.findChild("reported", "jabber:x:data"));
2125 layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2126 }
2127 break;
2128 }
2129 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2130 String url = el.findChildContent("url", "jabber:x:oob");
2131 if (url != null) {
2132 String scheme = Uri.parse(url).getScheme();
2133 if (scheme.equals("http") || scheme.equals("https")) {
2134 this.responseElement = el;
2135 break;
2136 }
2137 }
2138 }
2139 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2140 this.responseElement = el;
2141 break;
2142 }
2143 }
2144
2145 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2146 removeSession(this);
2147 return;
2148 }
2149
2150 if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2151 // No actions have been given, but we are not done?
2152 // This is probably a spec violation, but we should do *something*
2153 actionsAdapter.add("execute");
2154 }
2155
2156 if (!actionsAdapter.isEmpty()) {
2157 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2158 actionsAdapter.add("close");
2159 } else if (actionsAdapter.getPosition("cancel") < 0) {
2160 actionsAdapter.insert("cancel", 0);
2161 }
2162 }
2163 }
2164
2165 if (actionsAdapter.isEmpty()) {
2166 actionsAdapter.add("close");
2167 }
2168
2169 notifyDataSetChanged();
2170 }
2171
2172 protected void setupReported(Element el) {
2173 if (el == null) {
2174 reported = null;
2175 return;
2176 }
2177
2178 reported = new ArrayList<>();
2179 for (Element fieldEl : el.getChildren()) {
2180 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2181 reported.add(mkField(fieldEl));
2182 }
2183 }
2184
2185 @Override
2186 public int getItemCount() {
2187 if (loading) return 1;
2188 if (response == null) return 0;
2189 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2190 int i = 0;
2191 for (Element el : responseElement.getChildren()) {
2192 if (!el.getNamespace().equals("jabber:x:data")) continue;
2193 if (el.getName().equals("title")) continue;
2194 if (el.getName().equals("field")) {
2195 String type = el.getAttribute("type");
2196 if (type != null && type.equals("hidden")) continue;
2197 }
2198
2199 if (el.getName().equals("reported") || el.getName().equals("item")) {
2200 if (reported != null) i += reported.size();
2201 continue;
2202 }
2203
2204 i++;
2205 }
2206 return i;
2207 }
2208 return 1;
2209 }
2210
2211 public Item getItem(int position) {
2212 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2213 if (items.get(position) != null) return items.get(position);
2214 if (response == null) return null;
2215
2216 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2217 if (responseElement.getNamespace().equals("jabber:x:data")) {
2218 int i = 0;
2219 for (Element el : responseElement.getChildren()) {
2220 if (!el.getNamespace().equals("jabber:x:data")) continue;
2221 if (el.getName().equals("title")) continue;
2222 if (el.getName().equals("field")) {
2223 String type = el.getAttribute("type");
2224 if (type != null && type.equals("hidden")) continue;
2225 }
2226
2227 if (el.getName().equals("reported") || el.getName().equals("item")) {
2228 Cell cell = null;
2229
2230 if (reported != null) {
2231 if (reported.size() > position - i) {
2232 Field reportedField = reported.get(position - i);
2233 Element itemField = null;
2234 if (el.getName().equals("item")) {
2235 for (Element subel : el.getChildren()) {
2236 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2237 itemField = subel;
2238 break;
2239 }
2240 }
2241 }
2242 cell = new Cell(reportedField, itemField);
2243 } else {
2244 i += reported.size();
2245 continue;
2246 }
2247 }
2248
2249 if (cell != null) {
2250 items.put(position, cell);
2251 return cell;
2252 }
2253 }
2254
2255 if (i < position) {
2256 i++;
2257 continue;
2258 }
2259
2260 return mkItem(el, position);
2261 }
2262 }
2263 }
2264
2265 return mkItem(responseElement == null ? response : responseElement, position);
2266 }
2267
2268 @Override
2269 public int getItemViewType(int position) {
2270 return getItem(position).viewType;
2271 }
2272
2273 @Override
2274 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2275 switch(viewType) {
2276 case TYPE_ERROR: {
2277 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2278 return new ErrorViewHolder(binding);
2279 }
2280 case TYPE_NOTE: {
2281 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2282 return new NoteViewHolder(binding);
2283 }
2284 case TYPE_WEB: {
2285 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2286 return new WebViewHolder(binding);
2287 }
2288 case TYPE_RESULT_FIELD: {
2289 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2290 return new ResultFieldViewHolder(binding);
2291 }
2292 case TYPE_RESULT_CELL: {
2293 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2294 return new ResultCellViewHolder(binding);
2295 }
2296 case TYPE_CHECKBOX_FIELD: {
2297 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2298 return new CheckboxFieldViewHolder(binding);
2299 }
2300 case TYPE_SEARCH_LIST_FIELD: {
2301 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2302 return new SearchListFieldViewHolder(binding);
2303 }
2304 case TYPE_RADIO_EDIT_FIELD: {
2305 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2306 return new RadioEditFieldViewHolder(binding);
2307 }
2308 case TYPE_SPINNER_FIELD: {
2309 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2310 return new SpinnerFieldViewHolder(binding);
2311 }
2312 case TYPE_TEXT_FIELD: {
2313 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2314 return new TextFieldViewHolder(binding);
2315 }
2316 case TYPE_PROGRESSBAR: {
2317 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2318 return new ProgressBarViewHolder(binding);
2319 }
2320 default:
2321 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2322 }
2323 }
2324
2325 @Override
2326 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2327 viewHolder.bind(getItem(position));
2328 }
2329
2330 public View getView() {
2331 return mBinding.getRoot();
2332 }
2333
2334 public boolean validate() {
2335 int count = getItemCount();
2336 boolean isValid = true;
2337 for (int i = 0; i < count; i++) {
2338 boolean oneIsValid = getItem(i).validate();
2339 isValid = isValid && oneIsValid;
2340 }
2341 notifyDataSetChanged();
2342 return isValid;
2343 }
2344
2345 public boolean execute() {
2346 return execute("execute");
2347 }
2348
2349 public boolean execute(int actionPosition) {
2350 return execute(actionsAdapter.getItem(actionPosition));
2351 }
2352
2353 public boolean execute(String action) {
2354 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2355
2356 if (response == null) return true;
2357 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2358 if (command == null) return true;
2359 String status = command.getAttribute("status");
2360 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2361
2362 if (actionToWebview != null) {
2363 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2364 return false;
2365 }
2366
2367 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2368 packet.setTo(response.getFrom());
2369 final Element c = packet.addChild("command", Namespace.COMMANDS);
2370 c.setAttribute("node", command.getAttribute("node"));
2371 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2372 c.setAttribute("action", action);
2373
2374 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2375 if (!action.equals("cancel") &&
2376 !action.equals("prev") &&
2377 responseElement != null &&
2378 responseElement.getName().equals("x") &&
2379 responseElement.getNamespace().equals("jabber:x:data") &&
2380 formType != null && formType.equals("form")) {
2381
2382 responseElement.setAttribute("type", "submit");
2383 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2384 if (rsm != null) {
2385 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2386 max.setContent("1000");
2387 rsm.addChild(max);
2388 }
2389 c.addChild(responseElement);
2390 }
2391
2392 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2393 getView().post(() -> {
2394 updateWithResponse(iq);
2395 });
2396 });
2397
2398 loading();
2399 return false;
2400 }
2401
2402 protected void loading() {
2403 loadingTimer.schedule(new TimerTask() {
2404 @Override
2405 public void run() {
2406 getView().post(() -> {
2407 loading = true;
2408 notifyDataSetChanged();
2409 });
2410 }
2411 }, 500);
2412 }
2413
2414 protected GridLayoutManager setupLayoutManager() {
2415 layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2416 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2417 @Override
2418 public int getSpanSize(int position) {
2419 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2420 return 1;
2421 }
2422 });
2423 return layoutManager;
2424 }
2425
2426 public void setBinding(CommandPageBinding b) {
2427 mBinding = b;
2428 // https://stackoverflow.com/a/32350474/8611
2429 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2430 @Override
2431 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2432 if(rv.getChildCount() > 0) {
2433 int[] location = new int[2];
2434 rv.getLocationOnScreen(location);
2435 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2436 if (childView instanceof ViewGroup) {
2437 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2438 }
2439 if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2440 int action = e.getAction();
2441 switch (action) {
2442 case MotionEvent.ACTION_DOWN:
2443 rv.requestDisallowInterceptTouchEvent(true);
2444 }
2445 }
2446 }
2447
2448 return false;
2449 }
2450
2451 @Override
2452 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2453
2454 @Override
2455 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2456 });
2457 mBinding.form.setLayoutManager(setupLayoutManager());
2458 mBinding.form.setAdapter(this);
2459 mBinding.actions.setAdapter(actionsAdapter);
2460 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2461 if (execute(pos)) {
2462 removeSession(CommandSession.this);
2463 }
2464 });
2465
2466 actionsAdapter.notifyDataSetChanged();
2467 }
2468
2469 // https://stackoverflow.com/a/36037991/8611
2470 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2471 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2472 View child = viewGroup.getChildAt(i);
2473 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2474 View foundView = findViewAt((ViewGroup) child, x, y);
2475 if (foundView != null && foundView.isShown()) {
2476 return foundView;
2477 }
2478 } else {
2479 int[] location = new int[2];
2480 child.getLocationOnScreen(location);
2481 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2482 if (rect.contains((int)x, (int)y)) {
2483 return child;
2484 }
2485 }
2486 }
2487
2488 return null;
2489 }
2490 }
2491 }
2492}