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.WebView;
31import android.webkit.WebViewClient;
32import android.webkit.WebChromeClient;
33import android.util.SparseArray;
34
35import androidx.annotation.NonNull;
36import androidx.annotation.Nullable;
37import androidx.core.content.ContextCompat;
38import androidx.databinding.DataBindingUtil;
39import androidx.databinding.ViewDataBinding;
40import androidx.viewpager.widget.PagerAdapter;
41import androidx.recyclerview.widget.RecyclerView;
42import androidx.recyclerview.widget.GridLayoutManager;
43import androidx.viewpager.widget.ViewPager;
44
45import com.google.android.material.tabs.TabLayout;
46import com.google.android.material.textfield.TextInputLayout;
47import com.google.common.base.Optional;
48import com.google.common.collect.ComparisonChain;
49import com.google.common.collect.Lists;
50
51import org.json.JSONArray;
52import org.json.JSONException;
53import org.json.JSONObject;
54
55import java.util.ArrayList;
56import java.util.Collections;
57import java.util.Iterator;
58import java.util.List;
59import java.util.ListIterator;
60import java.util.concurrent.atomic.AtomicBoolean;
61import java.util.stream.Collectors;
62import java.util.Timer;
63import java.util.TimerTask;
64
65import eu.siacs.conversations.Config;
66import eu.siacs.conversations.R;
67import eu.siacs.conversations.crypto.OmemoSetting;
68import eu.siacs.conversations.crypto.PgpDecryptionService;
69import eu.siacs.conversations.databinding.CommandPageBinding;
70import eu.siacs.conversations.databinding.CommandNoteBinding;
71import eu.siacs.conversations.databinding.CommandResultFieldBinding;
72import eu.siacs.conversations.databinding.CommandResultCellBinding;
73import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
74import eu.siacs.conversations.databinding.CommandProgressBarBinding;
75import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
76import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
77import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
78import eu.siacs.conversations.databinding.CommandTextFieldBinding;
79import eu.siacs.conversations.databinding.CommandWebviewBinding;
80import eu.siacs.conversations.persistance.DatabaseBackend;
81import eu.siacs.conversations.services.AvatarService;
82import eu.siacs.conversations.services.QuickConversationsService;
83import eu.siacs.conversations.services.XmppConnectionService;
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 isWithStranger() {
1134 final Contact contact = getContact();
1135 return mode == MODE_SINGLE
1136 && !contact.isOwnServer()
1137 && !contact.showInContactList()
1138 && !contact.isSelf()
1139 && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1140 && sentMessagesCount() == 0;
1141 }
1142
1143 public int getReceivedMessagesCountSinceUuid(String uuid) {
1144 if (uuid == null) {
1145 return 0;
1146 }
1147 int count = 0;
1148 synchronized (this.messages) {
1149 for (int i = messages.size() - 1; i >= 0; i--) {
1150 final Message message = messages.get(i);
1151 if (uuid.equals(message.getUuid())) {
1152 return count;
1153 }
1154 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1155 ++count;
1156 }
1157 }
1158 }
1159 return 0;
1160 }
1161
1162 @Override
1163 public int getAvatarBackgroundColor() {
1164 return UIHelper.getColorForName(getName().toString());
1165 }
1166
1167 @Override
1168 public String getAvatarName() {
1169 return getName().toString();
1170 }
1171
1172 public void setCurrentTab(int tab) {
1173 mCurrentTab = tab;
1174 }
1175
1176 public int getCurrentTab() {
1177 if (mCurrentTab >= 0) return mCurrentTab;
1178
1179 if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1180 return 0;
1181 }
1182
1183 return 1;
1184 }
1185
1186 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1187 pagerAdapter.startCommand(command, xmppConnectionService);
1188 }
1189
1190 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1191 pagerAdapter.setupViewPager(pager, tabs);
1192 }
1193
1194 public void showViewPager() {
1195 pagerAdapter.show();
1196 }
1197
1198 public void hideViewPager() {
1199 pagerAdapter.hide();
1200 }
1201
1202 public interface OnMessageFound {
1203 void onMessageFound(final Message message);
1204 }
1205
1206 public static class Draft {
1207 private final String message;
1208 private final long timestamp;
1209
1210 private Draft(String message, long timestamp) {
1211 this.message = message;
1212 this.timestamp = timestamp;
1213 }
1214
1215 public long getTimestamp() {
1216 return timestamp;
1217 }
1218
1219 public String getMessage() {
1220 return message;
1221 }
1222 }
1223
1224 public class ConversationPagerAdapter extends PagerAdapter {
1225 protected ViewPager mPager = null;
1226 protected TabLayout mTabs = null;
1227 ArrayList<CommandSession> sessions = null;
1228
1229 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1230 mPager = pager;
1231 mTabs = tabs;
1232
1233 if (mPager == null) return;
1234 if (sessions != null) show();
1235
1236 pager.setAdapter(this);
1237 tabs.setupWithViewPager(mPager);
1238 pager.setCurrentItem(getCurrentTab());
1239
1240 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1241 public void onPageScrollStateChanged(int state) { }
1242 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1243
1244 public void onPageSelected(int position) {
1245 setCurrentTab(position);
1246 }
1247 });
1248 }
1249
1250 public void show() {
1251 if (sessions == null) {
1252 sessions = new ArrayList<>();
1253 notifyDataSetChanged();
1254 }
1255 if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
1256 }
1257
1258 public void hide() {
1259 if (sessions != null && !sessions.isEmpty()) return; // Do not hide during active session
1260 if (mPager != null) mPager.setCurrentItem(0);
1261 if (mTabs != null) mTabs.setVisibility(View.GONE);
1262 sessions = null;
1263 notifyDataSetChanged();
1264 }
1265
1266 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1267 show();
1268 CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1269
1270 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1271 packet.setTo(command.getAttributeAsJid("jid"));
1272 final Element c = packet.addChild("command", Namespace.COMMANDS);
1273 c.setAttribute("node", command.getAttribute("node"));
1274 c.setAttribute("action", "execute");
1275 View v = mPager;
1276 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1277 v.post(() -> {
1278 session.updateWithResponse(iq);
1279 });
1280 });
1281
1282 sessions.add(session);
1283 notifyDataSetChanged();
1284 mPager.setCurrentItem(getCount() - 1);
1285 }
1286
1287 public void removeSession(CommandSession session) {
1288 sessions.remove(session);
1289 notifyDataSetChanged();
1290 }
1291
1292 @NonNull
1293 @Override
1294 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1295 if (position < 2) {
1296 return mPager.getChildAt(position);
1297 }
1298
1299 CommandSession session = sessions.get(position-2);
1300 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1301 container.addView(binding.getRoot());
1302 session.setBinding(binding);
1303 return session;
1304 }
1305
1306 @Override
1307 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1308 if (position < 2) return;
1309
1310 container.removeView(((CommandSession) o).getView());
1311 }
1312
1313 @Override
1314 public int getItemPosition(Object o) {
1315 if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
1316 if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;
1317
1318 int pos = sessions == null ? -1 : sessions.indexOf(o);
1319 if (pos < 0) return PagerAdapter.POSITION_NONE;
1320 return pos + 2;
1321 }
1322
1323 @Override
1324 public int getCount() {
1325 if (sessions == null) return 1;
1326
1327 int count = 2 + sessions.size();
1328 if (count > 2) {
1329 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1330 } else {
1331 mTabs.setTabMode(TabLayout.MODE_FIXED);
1332 }
1333 return count;
1334 }
1335
1336 @Override
1337 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1338 if (view == o) return true;
1339
1340 if (o instanceof CommandSession) {
1341 return ((CommandSession) o).getView() == view;
1342 }
1343
1344 return false;
1345 }
1346
1347 @Nullable
1348 @Override
1349 public CharSequence getPageTitle(int position) {
1350 switch (position) {
1351 case 0:
1352 return "Conversation";
1353 case 1:
1354 return "Commands";
1355 default:
1356 CommandSession session = sessions.get(position-2);
1357 if (session == null) return super.getPageTitle(position);
1358 return session.getTitle();
1359 }
1360 }
1361
1362 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1363 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1364 protected T binding;
1365
1366 public ViewHolder(T binding) {
1367 super(binding.getRoot());
1368 this.binding = binding;
1369 }
1370
1371 abstract public void bind(Item el);
1372
1373 protected void setTextOrHide(TextView v, Optional<String> s) {
1374 if (s == null || !s.isPresent()) {
1375 v.setVisibility(View.GONE);
1376 } else {
1377 v.setVisibility(View.VISIBLE);
1378 v.setText(s.get());
1379 }
1380 }
1381
1382 protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1383 int flags = 0;
1384 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1385 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1386
1387 String type = field.getAttribute("type");
1388 if (type != null) {
1389 if (type.equals("text-multi") || type.equals("jid-multi")) {
1390 flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1391 }
1392
1393 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1394
1395 if (type.equals("jid-single") || type.equals("jid-multi")) {
1396 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1397 }
1398
1399 if (type.equals("text-private")) {
1400 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1401 if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1402 }
1403 }
1404
1405 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1406 if (validate == null) return;
1407 String datatype = validate.getAttribute("datatype");
1408 if (datatype == null) return;
1409
1410 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1411 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1412 }
1413
1414 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1415 textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1416 }
1417
1418 if (datatype.equals("xs:date")) {
1419 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1420 }
1421
1422 if (datatype.equals("xs:dateTime")) {
1423 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1424 }
1425
1426 if (datatype.equals("xs:time")) {
1427 textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1428 }
1429
1430 if (datatype.equals("xs:anyURI")) {
1431 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1432 }
1433
1434 if (datatype.equals("html:tel")) {
1435 textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1436 }
1437
1438 if (datatype.equals("html:email")) {
1439 textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1440 }
1441 }
1442 }
1443
1444 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1445 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1446
1447 @Override
1448 public void bind(Item iq) {
1449 binding.errorIcon.setVisibility(View.VISIBLE);
1450
1451 Element error = iq.el.findChild("error");
1452 if (error == null) return;
1453 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1454 if (text == null || text.equals("")) {
1455 text = error.getChildren().get(0).getName();
1456 }
1457 binding.message.setText(text);
1458 }
1459 }
1460
1461 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1462 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1463
1464 @Override
1465 public void bind(Item note) {
1466 binding.message.setText(note.el.getContent());
1467
1468 String type = note.el.getAttribute("type");
1469 if (type != null && type.equals("error")) {
1470 binding.errorIcon.setVisibility(View.VISIBLE);
1471 }
1472 }
1473 }
1474
1475 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1476 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1477
1478 @Override
1479 public void bind(Item item) {
1480 Field field = (Field) item;
1481 setTextOrHide(binding.label, field.getLabel());
1482 setTextOrHide(binding.desc, field.getDesc());
1483
1484 ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1485 for (Element el : field.el.getChildren()) {
1486 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1487 values.add(el.getContent());
1488 }
1489 }
1490 binding.values.setAdapter(values);
1491
1492 ClipboardManager clipboard = (ClipboardManager) binding.getRoot().getContext().getSystemService(Context.CLIPBOARD_SERVICE);
1493 binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1494 ClipData myClip = ClipData.newPlainText("text", values.getItem(pos));
1495 clipboard.setPrimaryClip(myClip);
1496 Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1497 return true;
1498 });
1499 }
1500 }
1501
1502 class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1503 public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1504
1505 @Override
1506 public void bind(Item item) {
1507 Cell cell = (Cell) item;
1508
1509 if (cell.el == null) {
1510 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1511 setTextOrHide(binding.text, cell.reported.getLabel());
1512 } else {
1513 binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1514 binding.text.setText(cell.el.findChildContent("value", "jabber:x:data"));
1515 }
1516 }
1517 }
1518
1519 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1520 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1521 super(binding);
1522 binding.row.setOnClickListener((v) -> {
1523 binding.checkbox.toggle();
1524 });
1525 binding.checkbox.setOnCheckedChangeListener(this);
1526 }
1527 protected Element mValue = null;
1528
1529 @Override
1530 public void bind(Item item) {
1531 Field field = (Field) item;
1532 binding.label.setText(field.getLabel().or(""));
1533 setTextOrHide(binding.desc, field.getDesc());
1534 mValue = field.getValue();
1535 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1536 }
1537
1538 @Override
1539 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1540 if (mValue == null) return;
1541
1542 mValue.setContent(isChecked ? "true" : "false");
1543 }
1544 }
1545
1546 class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1547 public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1548 super(binding);
1549 binding.search.addTextChangedListener(this);
1550 }
1551 protected Element mValue = null;
1552 List<Option> options = new ArrayList<>();
1553 protected ArrayAdapter<Option> adapter;
1554 protected boolean open;
1555
1556 @Override
1557 public void bind(Item item) {
1558 Field field = (Field) item;
1559 setTextOrHide(binding.label, field.getLabel());
1560 setTextOrHide(binding.desc, field.getDesc());
1561
1562 if (field.error != null) {
1563 binding.desc.setVisibility(View.VISIBLE);
1564 binding.desc.setText(field.error);
1565 binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Design_Error);
1566 } else {
1567 binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Status);
1568 }
1569
1570 mValue = field.getValue();
1571
1572 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1573 open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1574 setupInputType(field.el, binding.search, null);
1575
1576 options = field.getOptions();
1577 binding.list.setOnItemClickListener((parent, view, position, id) -> {
1578 mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1579 if (open) binding.search.setText(mValue.getContent());
1580 });
1581 search("");
1582 }
1583
1584 @Override
1585 public void afterTextChanged(Editable s) {
1586 if (open) mValue.setContent(s.toString());
1587 search(s.toString());
1588 }
1589
1590 @Override
1591 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1592
1593 @Override
1594 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1595
1596 protected void search(String s) {
1597 List<Option> filteredOptions;
1598 final String q = s.replaceAll("\\W", "").toLowerCase();
1599 if (q == null || q.equals("")) {
1600 filteredOptions = options;
1601 } else {
1602 filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1603 }
1604 adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1605 binding.list.setAdapter(adapter);
1606
1607 int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1608 if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1609 }
1610 }
1611
1612 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1613 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1614 super(binding);
1615 binding.open.addTextChangedListener(this);
1616 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1617 @Override
1618 public View getView(int position, View convertView, ViewGroup parent) {
1619 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1620 v.setId(position);
1621 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1622 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1623 return v;
1624 }
1625 };
1626 }
1627 protected Element mValue = null;
1628 protected ArrayAdapter<Option> options;
1629
1630 @Override
1631 public void bind(Item item) {
1632 Field field = (Field) item;
1633 setTextOrHide(binding.label, field.getLabel());
1634 setTextOrHide(binding.desc, field.getDesc());
1635
1636 if (field.error != null) {
1637 binding.desc.setVisibility(View.VISIBLE);
1638 binding.desc.setText(field.error);
1639 binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Design_Error);
1640 } else {
1641 binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Status);
1642 }
1643
1644 mValue = field.getValue();
1645
1646 Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1647 binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1648 binding.open.setText(mValue.getContent());
1649 setupInputType(field.el, binding.open, null);
1650
1651 options.clear();
1652 List<Option> theOptions = field.getOptions();
1653 options.addAll(theOptions);
1654
1655 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1656 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1657 float maxColumnWidth = theOptions.stream().map((x) ->
1658 StaticLayout.getDesiredWidth(x.toString(), paint)
1659 ).max(Float::compare).orElse(new Float(0.0));
1660 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1661 binding.radios.setNumColumns(theOptions.size());
1662 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1663 binding.radios.setNumColumns(theOptions.size() / 2);
1664 } else {
1665 binding.radios.setNumColumns(1);
1666 }
1667 binding.radios.setAdapter(options);
1668 }
1669
1670 @Override
1671 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1672 if (mValue == null) return;
1673
1674 if (isChecked) {
1675 mValue.setContent(options.getItem(radio.getId()).getValue());
1676 binding.open.setText(mValue.getContent());
1677 }
1678 options.notifyDataSetChanged();
1679 }
1680
1681 @Override
1682 public void afterTextChanged(Editable s) {
1683 if (mValue == null) return;
1684
1685 mValue.setContent(s.toString());
1686 options.notifyDataSetChanged();
1687 }
1688
1689 @Override
1690 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1691
1692 @Override
1693 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1694 }
1695
1696 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1697 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1698 super(binding);
1699 binding.spinner.setOnItemSelectedListener(this);
1700 }
1701 protected Element mValue = null;
1702
1703 @Override
1704 public void bind(Item item) {
1705 Field field = (Field) item;
1706 setTextOrHide(binding.label, field.getLabel());
1707 binding.spinner.setPrompt(field.getLabel().or(""));
1708 setTextOrHide(binding.desc, field.getDesc());
1709
1710 mValue = field.getValue();
1711
1712 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1713 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1714 options.addAll(field.getOptions());
1715
1716 binding.spinner.setAdapter(options);
1717 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1718 }
1719
1720 @Override
1721 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1722 Option o = (Option) parent.getItemAtPosition(pos);
1723 if (mValue == null) return;
1724
1725 mValue.setContent(o == null ? "" : o.getValue());
1726 }
1727
1728 @Override
1729 public void onNothingSelected(AdapterView<?> parent) {
1730 mValue.setContent("");
1731 }
1732 }
1733
1734 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1735 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1736 super(binding);
1737 binding.textinput.addTextChangedListener(this);
1738 }
1739 protected Element mValue = null;
1740
1741 @Override
1742 public void bind(Item item) {
1743 Field field = (Field) item;
1744 binding.textinputLayout.setHint(field.getLabel().or(""));
1745
1746 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1747 for (String desc : field.getDesc().asSet()) {
1748 binding.textinputLayout.setHelperText(desc);
1749 }
1750
1751 binding.textinputLayout.setErrorEnabled(field.error != null);
1752 if (field.error != null) binding.textinputLayout.setError(field.error);
1753
1754 mValue = field.getValue();
1755 binding.textinput.setText(mValue.getContent());
1756 setupInputType(field.el, binding.textinput, binding.textinputLayout);
1757 }
1758
1759 @Override
1760 public void afterTextChanged(Editable s) {
1761 if (mValue == null) return;
1762
1763 mValue.setContent(s.toString());
1764 }
1765
1766 @Override
1767 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1768
1769 @Override
1770 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1771 }
1772
1773 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1774 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1775
1776 @Override
1777 public void bind(Item oob) {
1778 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
1779 binding.webview.getSettings().setJavaScriptEnabled(true);
1780 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");
1781 binding.webview.getSettings().setDatabaseEnabled(true);
1782 binding.webview.getSettings().setDomStorageEnabled(true);
1783 binding.webview.setWebChromeClient(new WebChromeClient() {
1784 @Override
1785 public void onProgressChanged(WebView view, int newProgress) {
1786 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
1787 binding.progressbar.setProgress(newProgress);
1788 }
1789 });
1790 binding.webview.setWebViewClient(new WebViewClient() {
1791 @Override
1792 public void onPageFinished(WebView view, String url) {
1793 super.onPageFinished(view, url);
1794 mTitle = view.getTitle();
1795 ConversationPagerAdapter.this.notifyDataSetChanged();
1796 }
1797 });
1798 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
1799 binding.webview.loadUrl(oob.el.findChildContent("url", "jabber:x:oob"));
1800 }
1801
1802 class JsObject {
1803 @JavascriptInterface
1804 public void execute() { execute("execute"); }
1805 public void execute(String action) {
1806 getView().post(() -> {
1807 if(CommandSession.this.execute(action)) {
1808 removeSession(CommandSession.this);
1809 }
1810 });
1811 }
1812 }
1813 }
1814
1815 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
1816 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
1817
1818 @Override
1819 public void bind(Item item) { }
1820 }
1821
1822 class Item {
1823 protected Element el;
1824 protected int viewType;
1825 protected String error = null;
1826
1827 Item(Element el, int viewType) {
1828 this.el = el;
1829 this.viewType = viewType;
1830 }
1831
1832 public boolean validate() {
1833 error = null;
1834 return true;
1835 }
1836 }
1837
1838 class Field extends Item {
1839 Field(Element el, int viewType) { super(el, viewType); }
1840
1841 @Override
1842 public boolean validate() {
1843 if (!super.validate()) return false;
1844 if (el.findChild("required", "jabber:x:data") == null) return true;
1845 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
1846
1847 error = "this value is required";
1848 return false;
1849 }
1850
1851 public String getVar() {
1852 return el.getAttribute("var");
1853 }
1854
1855 public Optional<String> getLabel() {
1856 String label = el.getAttribute("label");
1857 if (label == null) label = getVar();
1858 return Optional.fromNullable(label);
1859 }
1860
1861 public Optional<String> getDesc() {
1862 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
1863 }
1864
1865 public Element getValue() {
1866 Element value = el.findChild("value", "jabber:x:data");
1867 if (value == null) {
1868 value = el.addChild("value", "jabber:x:data");
1869 }
1870 return value;
1871 }
1872
1873 public List<Option> getOptions() {
1874 return Option.forField(el);
1875 }
1876 }
1877
1878 class Cell extends Item {
1879 protected Field reported;
1880
1881 Cell(Field reported, Element item) {
1882 super(item, TYPE_RESULT_CELL);
1883 this.reported = reported;
1884 }
1885 }
1886
1887 protected Field mkField(Element el) {
1888 int viewType = -1;
1889
1890 String formType = responseElement.getAttribute("type");
1891 if (formType != null) {
1892 String fieldType = el.getAttribute("type");
1893 if (fieldType == null) fieldType = "text-single";
1894
1895 if (formType.equals("result") || fieldType.equals("fixed")) {
1896 viewType = TYPE_RESULT_FIELD;
1897 } else if (formType.equals("form")) {
1898 if (fieldType.equals("boolean")) {
1899 viewType = TYPE_CHECKBOX_FIELD;
1900 } else if (fieldType.equals("list-single")) {
1901 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1902 if (Option.forField(el).size() > 9) {
1903 viewType = TYPE_SEARCH_LIST_FIELD;
1904 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
1905 viewType = TYPE_RADIO_EDIT_FIELD;
1906 } else {
1907 viewType = TYPE_SPINNER_FIELD;
1908 }
1909 } else {
1910 viewType = TYPE_TEXT_FIELD;
1911 }
1912 }
1913
1914 return new Field(el, viewType);
1915 }
1916
1917 return null;
1918 }
1919
1920 protected Item mkItem(Element el, int pos) {
1921 int viewType = -1;
1922
1923 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
1924 if (el.getName().equals("note")) {
1925 viewType = TYPE_NOTE;
1926 } else if (el.getNamespace().equals("jabber:x:oob")) {
1927 viewType = TYPE_WEB;
1928 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
1929 viewType = TYPE_NOTE;
1930 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
1931 Field field = mkField(el);
1932 if (field != null) {
1933 items.put(pos, field);
1934 return field;
1935 }
1936 }
1937 } else if (response != null) {
1938 viewType = TYPE_ERROR;
1939 }
1940
1941 Item item = new Item(el, viewType);
1942 items.put(pos, item);
1943 return item;
1944 }
1945
1946 final int TYPE_ERROR = 1;
1947 final int TYPE_NOTE = 2;
1948 final int TYPE_WEB = 3;
1949 final int TYPE_RESULT_FIELD = 4;
1950 final int TYPE_TEXT_FIELD = 5;
1951 final int TYPE_CHECKBOX_FIELD = 6;
1952 final int TYPE_SPINNER_FIELD = 7;
1953 final int TYPE_RADIO_EDIT_FIELD = 8;
1954 final int TYPE_RESULT_CELL = 9;
1955 final int TYPE_PROGRESSBAR = 10;
1956 final int TYPE_SEARCH_LIST_FIELD = 11;
1957
1958 protected boolean loading = false;
1959 protected Timer loadingTimer = new Timer();
1960 protected String mTitle;
1961 protected CommandPageBinding mBinding = null;
1962 protected IqPacket response = null;
1963 protected Element responseElement = null;
1964 protected List<Field> reported = null;
1965 protected SparseArray<Item> items = new SparseArray<>();
1966 protected XmppConnectionService xmppConnectionService;
1967 protected ArrayAdapter<String> actionsAdapter;
1968 protected GridLayoutManager layoutManager;
1969
1970 CommandSession(String title, XmppConnectionService xmppConnectionService) {
1971 loading();
1972 mTitle = title;
1973 this.xmppConnectionService = xmppConnectionService;
1974 if (mPager != null) setupLayoutManager();
1975 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
1976 @Override
1977 public View getView(int position, View convertView, ViewGroup parent) {
1978 View v = super.getView(position, convertView, parent);
1979 TextView tv = (TextView) v.findViewById(android.R.id.text1);
1980 tv.setGravity(Gravity.CENTER);
1981 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
1982 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
1983 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
1984 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
1985 return v;
1986 }
1987 };
1988 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
1989 @Override
1990 public void onChanged() {
1991 if (mBinding == null) return;
1992
1993 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
1994 }
1995
1996 @Override
1997 public void onInvalidated() {}
1998 });
1999 }
2000
2001 public String getTitle() {
2002 return mTitle;
2003 }
2004
2005 public void updateWithResponse(IqPacket iq) {
2006 this.loadingTimer.cancel();
2007 this.loadingTimer = new Timer();
2008 this.loading = false;
2009 this.responseElement = null;
2010 this.reported = null;
2011 this.response = iq;
2012 this.items.clear();
2013 this.actionsAdapter.clear();
2014 layoutManager.setSpanCount(1);
2015
2016 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2017 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2018 for (Element el : command.getChildren()) {
2019 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2020 for (Element action : el.getChildren()) {
2021 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2022 if (action.getName().equals("execute")) continue;
2023
2024 actionsAdapter.add(action.getName());
2025 }
2026 }
2027 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2028 String title = el.findChildContent("title", "jabber:x:data");
2029 if (title != null) {
2030 mTitle = title;
2031 ConversationPagerAdapter.this.notifyDataSetChanged();
2032 }
2033
2034 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2035 this.responseElement = el;
2036 setupReported(el.findChild("reported", "jabber:x:data"));
2037 layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
2038 }
2039 break;
2040 }
2041 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2042 String url = el.findChildContent("url", "jabber:x:oob");
2043 if (url != null) {
2044 String scheme = Uri.parse(url).getScheme();
2045 if (scheme.equals("http") || scheme.equals("https")) {
2046 this.responseElement = el;
2047 break;
2048 }
2049 }
2050 }
2051 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2052 this.responseElement = el;
2053 break;
2054 }
2055 }
2056
2057 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2058 removeSession(this);
2059 return;
2060 }
2061
2062 if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
2063 // No actions have been given, but we are not done?
2064 // This is probably a spec violation, but we should do *something*
2065 actionsAdapter.add("execute");
2066 }
2067
2068 if (!actionsAdapter.isEmpty()) {
2069 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2070 actionsAdapter.add("close");
2071 } else if (actionsAdapter.getPosition("cancel") < 0) {
2072 actionsAdapter.insert("cancel", 0);
2073 }
2074 }
2075 }
2076
2077 if (actionsAdapter.isEmpty()) {
2078 actionsAdapter.add("close");
2079 }
2080
2081 notifyDataSetChanged();
2082 }
2083
2084 protected void setupReported(Element el) {
2085 if (el == null) {
2086 reported = null;
2087 return;
2088 }
2089
2090 reported = new ArrayList<>();
2091 for (Element fieldEl : el.getChildren()) {
2092 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2093 reported.add(mkField(fieldEl));
2094 }
2095 }
2096
2097 @Override
2098 public int getItemCount() {
2099 if (loading) return 1;
2100 if (response == null) return 0;
2101 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2102 int i = 0;
2103 for (Element el : responseElement.getChildren()) {
2104 if (!el.getNamespace().equals("jabber:x:data")) continue;
2105 if (el.getName().equals("title")) continue;
2106 if (el.getName().equals("field")) {
2107 String type = el.getAttribute("type");
2108 if (type != null && type.equals("hidden")) continue;
2109 }
2110
2111 if (el.getName().equals("reported") || el.getName().equals("item")) {
2112 if (reported != null) i += reported.size();
2113 continue;
2114 }
2115
2116 i++;
2117 }
2118 return i;
2119 }
2120 return 1;
2121 }
2122
2123 public Item getItem(int position) {
2124 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2125 if (items.get(position) != null) return items.get(position);
2126 if (response == null) return null;
2127
2128 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2129 if (responseElement.getNamespace().equals("jabber:x:data")) {
2130 int i = 0;
2131 for (Element el : responseElement.getChildren()) {
2132 if (!el.getNamespace().equals("jabber:x:data")) continue;
2133 if (el.getName().equals("title")) continue;
2134 if (el.getName().equals("field")) {
2135 String type = el.getAttribute("type");
2136 if (type != null && type.equals("hidden")) continue;
2137 }
2138
2139 if (el.getName().equals("reported") || el.getName().equals("item")) {
2140 Cell cell = null;
2141
2142 if (reported != null) {
2143 if (reported.size() > position - i) {
2144 Field reportedField = reported.get(position - i);
2145 Element itemField = null;
2146 if (el.getName().equals("item")) {
2147 for (Element subel : el.getChildren()) {
2148 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2149 itemField = subel;
2150 break;
2151 }
2152 }
2153 }
2154 cell = new Cell(reportedField, itemField);
2155 } else {
2156 i += reported.size();
2157 continue;
2158 }
2159 }
2160
2161 if (cell != null) {
2162 items.put(position, cell);
2163 return cell;
2164 }
2165 }
2166
2167 if (i < position) {
2168 i++;
2169 continue;
2170 }
2171
2172 return mkItem(el, position);
2173 }
2174 }
2175 }
2176
2177 return mkItem(responseElement == null ? response : responseElement, position);
2178 }
2179
2180 @Override
2181 public int getItemViewType(int position) {
2182 return getItem(position).viewType;
2183 }
2184
2185 @Override
2186 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2187 switch(viewType) {
2188 case TYPE_ERROR: {
2189 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2190 return new ErrorViewHolder(binding);
2191 }
2192 case TYPE_NOTE: {
2193 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2194 return new NoteViewHolder(binding);
2195 }
2196 case TYPE_WEB: {
2197 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2198 return new WebViewHolder(binding);
2199 }
2200 case TYPE_RESULT_FIELD: {
2201 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2202 return new ResultFieldViewHolder(binding);
2203 }
2204 case TYPE_RESULT_CELL: {
2205 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2206 return new ResultCellViewHolder(binding);
2207 }
2208 case TYPE_CHECKBOX_FIELD: {
2209 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2210 return new CheckboxFieldViewHolder(binding);
2211 }
2212 case TYPE_SEARCH_LIST_FIELD: {
2213 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2214 return new SearchListFieldViewHolder(binding);
2215 }
2216 case TYPE_RADIO_EDIT_FIELD: {
2217 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2218 return new RadioEditFieldViewHolder(binding);
2219 }
2220 case TYPE_SPINNER_FIELD: {
2221 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2222 return new SpinnerFieldViewHolder(binding);
2223 }
2224 case TYPE_TEXT_FIELD: {
2225 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2226 return new TextFieldViewHolder(binding);
2227 }
2228 case TYPE_PROGRESSBAR: {
2229 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2230 return new ProgressBarViewHolder(binding);
2231 }
2232 default:
2233 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2234 }
2235 }
2236
2237 @Override
2238 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2239 viewHolder.bind(getItem(position));
2240 }
2241
2242 public View getView() {
2243 return mBinding.getRoot();
2244 }
2245
2246 public boolean validate() {
2247 int count = getItemCount();
2248 boolean isValid = true;
2249 for (int i = 0; i < count; i++) {
2250 boolean oneIsValid = getItem(i).validate();
2251 isValid = isValid && oneIsValid;
2252 }
2253 notifyDataSetChanged();
2254 return isValid;
2255 }
2256
2257 public boolean execute() {
2258 return execute("execute");
2259 }
2260
2261 public boolean execute(int actionPosition) {
2262 return execute(actionsAdapter.getItem(actionPosition));
2263 }
2264
2265 public boolean execute(String action) {
2266 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2267 if (response == null) return true;
2268 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2269 if (command == null) return true;
2270 String status = command.getAttribute("status");
2271 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2272
2273 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2274 packet.setTo(response.getFrom());
2275 final Element c = packet.addChild("command", Namespace.COMMANDS);
2276 c.setAttribute("node", command.getAttribute("node"));
2277 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2278 c.setAttribute("action", action);
2279
2280 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2281 if (!action.equals("cancel") &&
2282 !action.equals("prev") &&
2283 responseElement != null &&
2284 responseElement.getName().equals("x") &&
2285 responseElement.getNamespace().equals("jabber:x:data") &&
2286 formType != null && formType.equals("form")) {
2287
2288 responseElement.setAttribute("type", "submit");
2289 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2290 if (rsm != null) {
2291 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2292 max.setContent("1000");
2293 rsm.addChild(max);
2294 }
2295 c.addChild(responseElement);
2296 }
2297
2298 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2299 getView().post(() -> {
2300 updateWithResponse(iq);
2301 });
2302 });
2303
2304 loading();
2305 return false;
2306 }
2307
2308 protected void loading() {
2309 loadingTimer.schedule(new TimerTask() {
2310 @Override
2311 public void run() {
2312 getView().post(() -> {
2313 loading = true;
2314 notifyDataSetChanged();
2315 });
2316 }
2317 }, 500);
2318 }
2319
2320 protected GridLayoutManager setupLayoutManager() {
2321 layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
2322 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2323 @Override
2324 public int getSpanSize(int position) {
2325 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2326 return 1;
2327 }
2328 });
2329 return layoutManager;
2330 }
2331
2332 public void setBinding(CommandPageBinding b) {
2333 mBinding = b;
2334 // https://stackoverflow.com/a/32350474/8611
2335 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2336 @Override
2337 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2338 if(rv.getChildCount() > 0) {
2339 int[] location = new int[2];
2340 rv.getLocationOnScreen(location);
2341 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2342 if (childView instanceof ViewGroup) {
2343 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2344 }
2345 if (childView instanceof ListView || childView instanceof WebView) {
2346 int action = e.getAction();
2347 switch (action) {
2348 case MotionEvent.ACTION_DOWN:
2349 rv.requestDisallowInterceptTouchEvent(true);
2350 }
2351 }
2352 }
2353
2354 return false;
2355 }
2356
2357 @Override
2358 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2359
2360 @Override
2361 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2362 });
2363 mBinding.form.setLayoutManager(setupLayoutManager());
2364 mBinding.form.setAdapter(this);
2365 mBinding.actions.setAdapter(actionsAdapter);
2366 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2367 if (execute(pos)) {
2368 removeSession(CommandSession.this);
2369 }
2370 });
2371
2372 actionsAdapter.notifyDataSetChanged();
2373 }
2374
2375 // https://stackoverflow.com/a/36037991/8611
2376 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2377 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2378 View child = viewGroup.getChildAt(i);
2379 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2380 View foundView = findViewAt((ViewGroup) child, x, y);
2381 if (foundView != null && foundView.isShown()) {
2382 return foundView;
2383 }
2384 } else {
2385 int[] location = new int[2];
2386 child.getLocationOnScreen(location);
2387 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2388 if (rect.contains((int)x, (int)y)) {
2389 return child;
2390 }
2391 }
2392 }
2393
2394 return null;
2395 }
2396 }
2397 }
2398}