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