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