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