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"), command.getAttribute("node"), 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 String mNode;
2049 protected CommandPageBinding mBinding = null;
2050 protected IqPacket response = null;
2051 protected Element responseElement = null;
2052 protected List<Field> reported = null;
2053 protected SparseArray<Item> items = new SparseArray<>();
2054 protected XmppConnectionService xmppConnectionService;
2055 protected ArrayAdapter<String> actionsAdapter;
2056 protected GridLayoutManager layoutManager;
2057 protected WebView actionToWebview = null;
2058
2059 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2060 loading();
2061 mTitle = title;
2062 mNode = node;
2063 this.xmppConnectionService = xmppConnectionService;
2064 if (mPager != null) setupLayoutManager();
2065 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
2066 @Override
2067 public View getView(int position, View convertView, ViewGroup parent) {
2068 View v = super.getView(position, convertView, parent);
2069 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2070 tv.setGravity(Gravity.CENTER);
2071 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
2072 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
2073 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
2074 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
2075 return v;
2076 }
2077 };
2078 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2079 @Override
2080 public void onChanged() {
2081 if (mBinding == null) return;
2082
2083 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2084 }
2085
2086 @Override
2087 public void onInvalidated() {}
2088 });
2089 }
2090
2091 public String getTitle() {
2092 return mTitle;
2093 }
2094
2095 public void updateWithResponse(IqPacket iq) {
2096 this.loadingTimer.cancel();
2097 this.loadingTimer = new Timer();
2098 this.loading = false;
2099 this.responseElement = null;
2100 this.reported = null;
2101 this.response = iq;
2102 this.items.clear();
2103 this.actionsAdapter.clear();
2104 layoutManager.setSpanCount(1);
2105
2106 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2107 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2108 if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2109 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2110 }
2111
2112 for (Element el : command.getChildren()) {
2113 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2114 for (Element action : el.getChildren()) {
2115 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2116 if (action.getName().equals("execute")) continue;
2117
2118 actionsAdapter.add(action.getName());
2119 }
2120 }
2121 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2122 String title = el.findChildContent("title", "jabber:x:data");
2123 if (title != null) {
2124 mTitle = title;
2125 ConversationPagerAdapter.this.notifyDataSetChanged();
2126 }
2127
2128 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2129 this.responseElement = el;
2130 setupReported(el.findChild("reported", "jabber:x:data"));
2131 layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2132 }
2133 break;
2134 }
2135 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2136 String url = el.findChildContent("url", "jabber:x:oob");
2137 if (url != null) {
2138 String scheme = Uri.parse(url).getScheme();
2139 if (scheme.equals("http") || scheme.equals("https")) {
2140 this.responseElement = el;
2141 break;
2142 }
2143 }
2144 }
2145 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2146 this.responseElement = el;
2147 break;
2148 }
2149 }
2150
2151 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2152 removeSession(this);
2153 return;
2154 }
2155
2156 if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2157 // No actions have been given, but we are not done?
2158 // This is probably a spec violation, but we should do *something*
2159 actionsAdapter.add("execute");
2160 }
2161
2162 if (!actionsAdapter.isEmpty()) {
2163 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2164 actionsAdapter.add("close");
2165 } else if (actionsAdapter.getPosition("cancel") < 0) {
2166 actionsAdapter.insert("cancel", 0);
2167 }
2168 }
2169 }
2170
2171 if (actionsAdapter.isEmpty()) {
2172 actionsAdapter.add("close");
2173 }
2174
2175 notifyDataSetChanged();
2176 }
2177
2178 protected void setupReported(Element el) {
2179 if (el == null) {
2180 reported = null;
2181 return;
2182 }
2183
2184 reported = new ArrayList<>();
2185 for (Element fieldEl : el.getChildren()) {
2186 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2187 reported.add(mkField(fieldEl));
2188 }
2189 }
2190
2191 @Override
2192 public int getItemCount() {
2193 if (loading) return 1;
2194 if (response == null) return 0;
2195 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2196 int i = 0;
2197 for (Element el : responseElement.getChildren()) {
2198 if (!el.getNamespace().equals("jabber:x:data")) continue;
2199 if (el.getName().equals("title")) continue;
2200 if (el.getName().equals("field")) {
2201 String type = el.getAttribute("type");
2202 if (type != null && type.equals("hidden")) continue;
2203 }
2204
2205 if (el.getName().equals("reported") || el.getName().equals("item")) {
2206 if (reported != null) i += reported.size();
2207 continue;
2208 }
2209
2210 i++;
2211 }
2212 return i;
2213 }
2214 return 1;
2215 }
2216
2217 public Item getItem(int position) {
2218 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2219 if (items.get(position) != null) return items.get(position);
2220 if (response == null) return null;
2221
2222 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2223 if (responseElement.getNamespace().equals("jabber:x:data")) {
2224 int i = 0;
2225 for (Element el : responseElement.getChildren()) {
2226 if (!el.getNamespace().equals("jabber:x:data")) continue;
2227 if (el.getName().equals("title")) continue;
2228 if (el.getName().equals("field")) {
2229 String type = el.getAttribute("type");
2230 if (type != null && type.equals("hidden")) continue;
2231 }
2232
2233 if (el.getName().equals("reported") || el.getName().equals("item")) {
2234 Cell cell = null;
2235
2236 if (reported != null) {
2237 if (reported.size() > position - i) {
2238 Field reportedField = reported.get(position - i);
2239 Element itemField = null;
2240 if (el.getName().equals("item")) {
2241 for (Element subel : el.getChildren()) {
2242 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2243 itemField = subel;
2244 break;
2245 }
2246 }
2247 }
2248 cell = new Cell(reportedField, itemField);
2249 } else {
2250 i += reported.size();
2251 continue;
2252 }
2253 }
2254
2255 if (cell != null) {
2256 items.put(position, cell);
2257 return cell;
2258 }
2259 }
2260
2261 if (i < position) {
2262 i++;
2263 continue;
2264 }
2265
2266 return mkItem(el, position);
2267 }
2268 }
2269 }
2270
2271 return mkItem(responseElement == null ? response : responseElement, position);
2272 }
2273
2274 @Override
2275 public int getItemViewType(int position) {
2276 return getItem(position).viewType;
2277 }
2278
2279 @Override
2280 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2281 switch(viewType) {
2282 case TYPE_ERROR: {
2283 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2284 return new ErrorViewHolder(binding);
2285 }
2286 case TYPE_NOTE: {
2287 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2288 return new NoteViewHolder(binding);
2289 }
2290 case TYPE_WEB: {
2291 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2292 return new WebViewHolder(binding);
2293 }
2294 case TYPE_RESULT_FIELD: {
2295 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2296 return new ResultFieldViewHolder(binding);
2297 }
2298 case TYPE_RESULT_CELL: {
2299 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2300 return new ResultCellViewHolder(binding);
2301 }
2302 case TYPE_CHECKBOX_FIELD: {
2303 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2304 return new CheckboxFieldViewHolder(binding);
2305 }
2306 case TYPE_SEARCH_LIST_FIELD: {
2307 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2308 return new SearchListFieldViewHolder(binding);
2309 }
2310 case TYPE_RADIO_EDIT_FIELD: {
2311 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2312 return new RadioEditFieldViewHolder(binding);
2313 }
2314 case TYPE_SPINNER_FIELD: {
2315 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2316 return new SpinnerFieldViewHolder(binding);
2317 }
2318 case TYPE_TEXT_FIELD: {
2319 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2320 return new TextFieldViewHolder(binding);
2321 }
2322 case TYPE_PROGRESSBAR: {
2323 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2324 return new ProgressBarViewHolder(binding);
2325 }
2326 default:
2327 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2328 }
2329 }
2330
2331 @Override
2332 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2333 viewHolder.bind(getItem(position));
2334 }
2335
2336 public View getView() {
2337 return mBinding.getRoot();
2338 }
2339
2340 public boolean validate() {
2341 int count = getItemCount();
2342 boolean isValid = true;
2343 for (int i = 0; i < count; i++) {
2344 boolean oneIsValid = getItem(i).validate();
2345 isValid = isValid && oneIsValid;
2346 }
2347 notifyDataSetChanged();
2348 return isValid;
2349 }
2350
2351 public boolean execute() {
2352 return execute("execute");
2353 }
2354
2355 public boolean execute(int actionPosition) {
2356 return execute(actionsAdapter.getItem(actionPosition));
2357 }
2358
2359 public boolean execute(String action) {
2360 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2361
2362 if (response == null) return true;
2363 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2364 if (command == null) return true;
2365 String status = command.getAttribute("status");
2366 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2367
2368 if (actionToWebview != null) {
2369 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2370 return false;
2371 }
2372
2373 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2374 packet.setTo(response.getFrom());
2375 final Element c = packet.addChild("command", Namespace.COMMANDS);
2376 c.setAttribute("node", mNode);
2377 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2378 c.setAttribute("action", action);
2379
2380 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2381 if (!action.equals("cancel") &&
2382 !action.equals("prev") &&
2383 responseElement != null &&
2384 responseElement.getName().equals("x") &&
2385 responseElement.getNamespace().equals("jabber:x:data") &&
2386 formType != null && formType.equals("form")) {
2387
2388 responseElement.setAttribute("type", "submit");
2389 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2390 if (rsm != null) {
2391 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2392 max.setContent("1000");
2393 rsm.addChild(max);
2394 }
2395 c.addChild(responseElement);
2396 }
2397
2398 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2399 getView().post(() -> {
2400 updateWithResponse(iq);
2401 });
2402 });
2403
2404 loading();
2405 return false;
2406 }
2407
2408 protected void loading() {
2409 loadingTimer.schedule(new TimerTask() {
2410 @Override
2411 public void run() {
2412 getView().post(() -> {
2413 loading = true;
2414 notifyDataSetChanged();
2415 });
2416 }
2417 }, 500);
2418 }
2419
2420 protected GridLayoutManager setupLayoutManager() {
2421 layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2422 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2423 @Override
2424 public int getSpanSize(int position) {
2425 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2426 return 1;
2427 }
2428 });
2429 return layoutManager;
2430 }
2431
2432 public void setBinding(CommandPageBinding b) {
2433 mBinding = b;
2434 // https://stackoverflow.com/a/32350474/8611
2435 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2436 @Override
2437 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2438 if(rv.getChildCount() > 0) {
2439 int[] location = new int[2];
2440 rv.getLocationOnScreen(location);
2441 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2442 if (childView instanceof ViewGroup) {
2443 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2444 }
2445 if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2446 int action = e.getAction();
2447 switch (action) {
2448 case MotionEvent.ACTION_DOWN:
2449 rv.requestDisallowInterceptTouchEvent(true);
2450 }
2451 }
2452 }
2453
2454 return false;
2455 }
2456
2457 @Override
2458 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2459
2460 @Override
2461 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2462 });
2463 mBinding.form.setLayoutManager(setupLayoutManager());
2464 mBinding.form.setAdapter(this);
2465 mBinding.actions.setAdapter(actionsAdapter);
2466 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2467 if (execute(pos)) {
2468 removeSession(CommandSession.this);
2469 }
2470 });
2471
2472 actionsAdapter.notifyDataSetChanged();
2473 }
2474
2475 // https://stackoverflow.com/a/36037991/8611
2476 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2477 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2478 View child = viewGroup.getChildAt(i);
2479 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2480 View foundView = findViewAt((ViewGroup) child, x, y);
2481 if (foundView != null && foundView.isShown()) {
2482 return foundView;
2483 }
2484 } else {
2485 int[] location = new int[2];
2486 child.getLocationOnScreen(location);
2487 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2488 if (rect.contains((int)x, (int)y)) {
2489 return child;
2490 }
2491 }
2492 }
2493
2494 return null;
2495 }
2496 }
2497 }
2498}