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