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