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