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