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