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