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 interface OnMessageFound {
1178 void onMessageFound(final Message message);
1179 }
1180
1181 public static class Draft {
1182 private final String message;
1183 private final long timestamp;
1184
1185 private Draft(String message, long timestamp) {
1186 this.message = message;
1187 this.timestamp = timestamp;
1188 }
1189
1190 public long getTimestamp() {
1191 return timestamp;
1192 }
1193
1194 public String getMessage() {
1195 return message;
1196 }
1197 }
1198
1199 public class ConversationPagerAdapter extends PagerAdapter {
1200 protected ViewPager mPager = null;
1201 protected TabLayout mTabs = null;
1202 ArrayList<CommandSession> sessions = new ArrayList<>();
1203
1204 public void setupViewPager(ViewPager pager, TabLayout tabs) {
1205 mPager = pager;
1206 mTabs = tabs;
1207 pager.setAdapter(this);
1208 tabs.setupWithViewPager(mPager);
1209 pager.setCurrentItem(getCurrentTab());
1210
1211 mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1212 public void onPageScrollStateChanged(int state) { }
1213 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1214
1215 public void onPageSelected(int position) {
1216 setCurrentTab(position);
1217 }
1218 });
1219 }
1220
1221 public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1222 CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1223
1224 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1225 packet.setTo(command.getAttributeAsJid("jid"));
1226 final Element c = packet.addChild("command", Namespace.COMMANDS);
1227 c.setAttribute("node", command.getAttribute("node"));
1228 c.setAttribute("action", "execute");
1229 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1230 mPager.post(() -> {
1231 session.updateWithResponse(iq);
1232 });
1233 });
1234
1235 sessions.add(session);
1236 notifyDataSetChanged();
1237 mPager.setCurrentItem(getCount() - 1);
1238 }
1239
1240 public void removeSession(CommandSession session) {
1241 sessions.remove(session);
1242 notifyDataSetChanged();
1243 }
1244
1245 @NonNull
1246 @Override
1247 public Object instantiateItem(@NonNull ViewGroup container, int position) {
1248 if (position < 2) {
1249 return mPager.getChildAt(position);
1250 }
1251
1252 CommandSession session = sessions.get(position-2);
1253 CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1254 container.addView(binding.getRoot());
1255 session.setBinding(binding);
1256 return session;
1257 }
1258
1259 @Override
1260 public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1261 if (position < 2) return;
1262
1263 container.removeView(((CommandSession) o).getView());
1264 }
1265
1266 @Override
1267 public int getItemPosition(Object o) {
1268 if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
1269 if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;
1270
1271 int pos = sessions.indexOf(o);
1272 if (pos < 0) return PagerAdapter.POSITION_NONE;
1273 return pos + 2;
1274 }
1275
1276 @Override
1277 public int getCount() {
1278 int count = 2 + sessions.size();
1279 if (count > 2) {
1280 mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1281 } else {
1282 mTabs.setTabMode(TabLayout.MODE_FIXED);
1283 }
1284 return count;
1285 }
1286
1287 @Override
1288 public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1289 if (view == o) return true;
1290
1291 if (o instanceof CommandSession) {
1292 return ((CommandSession) o).getView() == view;
1293 }
1294
1295 return false;
1296 }
1297
1298 @Nullable
1299 @Override
1300 public CharSequence getPageTitle(int position) {
1301 switch (position) {
1302 case 0:
1303 return "Conversation";
1304 case 1:
1305 return "Commands";
1306 default:
1307 CommandSession session = sessions.get(position-2);
1308 if (session == null) return super.getPageTitle(position);
1309 return session.getTitle();
1310 }
1311 }
1312
1313 class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1314 abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1315 protected T binding;
1316
1317 public ViewHolder(T binding) {
1318 super(binding.getRoot());
1319 this.binding = binding;
1320 }
1321
1322 abstract public void bind(Element el);
1323 }
1324
1325 class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1326 public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1327
1328 @Override
1329 public void bind(Element iq) {
1330 binding.errorIcon.setVisibility(View.VISIBLE);
1331
1332 Element error = iq.findChild("error");
1333 if (error == null) return;
1334 String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1335 if (text == null || text.equals("")) {
1336 text = error.getChildren().get(0).getName();
1337 }
1338 binding.message.setText(text);
1339 }
1340 }
1341
1342 class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1343 public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1344
1345 @Override
1346 public void bind(Element note) {
1347 binding.message.setText(note.getContent());
1348
1349 String type = note.getAttribute("type");
1350 if (type != null && type.equals("error")) {
1351 binding.errorIcon.setVisibility(View.VISIBLE);
1352 }
1353 }
1354 }
1355
1356 class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1357 public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1358
1359 @Override
1360 public void bind(Element field) {
1361 String label = field.getAttribute("label");
1362 if (label == null) label = field.getAttribute("var");
1363 if (label == null) {
1364 binding.label.setVisibility(View.GONE);
1365 } else {
1366 binding.label.setVisibility(View.VISIBLE);
1367 binding.label.setText(label);
1368 }
1369
1370 String desc = field.findChildContent("desc", "jabber:x:data");
1371 if (desc == null) {
1372 binding.desc.setVisibility(View.GONE);
1373 } else {
1374 binding.desc.setVisibility(View.VISIBLE);
1375 binding.desc.setText(desc);
1376 }
1377
1378 ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1379 for (Element el : field.getChildren()) {
1380 if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1381 values.add(el.getContent());
1382 }
1383 }
1384 binding.values.setAdapter(values);
1385 }
1386 }
1387
1388 class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1389 public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1390 super(binding);
1391 binding.row.setOnClickListener((v) -> {
1392 binding.checkbox.toggle();
1393 });
1394 binding.checkbox.setOnCheckedChangeListener(this);
1395 }
1396 protected Element mValue = null;
1397
1398 @Override
1399 public void bind(Element field) {
1400 String label = field.getAttribute("label");
1401 if (label == null) label = field.getAttribute("var");
1402 if (label == null) label = "";
1403 binding.label.setText(label);
1404
1405 String desc = field.findChildContent("desc", "jabber:x:data");
1406 if (desc == null) {
1407 binding.desc.setVisibility(View.GONE);
1408 } else {
1409 binding.desc.setVisibility(View.VISIBLE);
1410 binding.desc.setText(desc);
1411 }
1412
1413 mValue = field.findChild("value", "jabber:x:data");
1414 if (mValue == null) {
1415 mValue = field.addChild("value", "jabber:x:data");
1416 }
1417
1418 binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1419 }
1420
1421 @Override
1422 public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1423 if (mValue == null) return;
1424
1425 mValue.setContent(isChecked ? "true" : "false");
1426 }
1427 }
1428
1429 class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1430 public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1431 super(binding);
1432 options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1433 @Override
1434 public View getView(int position, View convertView, ViewGroup parent) {
1435 CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1436 v.setId(position);
1437 v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1438 v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1439 return v;
1440 }
1441 };
1442 }
1443 protected Element mValue = null;
1444 protected ArrayAdapter<Option> options;
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) {
1451 binding.label.setVisibility(View.GONE);
1452 } else {
1453 binding.label.setVisibility(View.VISIBLE);
1454 binding.label.setText(label);
1455 }
1456
1457 String desc = field.findChildContent("desc", "jabber:x:data");
1458 if (desc == null) {
1459 binding.desc.setVisibility(View.GONE);
1460 } else {
1461 binding.desc.setVisibility(View.VISIBLE);
1462 binding.desc.setText(desc);
1463 }
1464
1465 mValue = field.findChild("value", "jabber:x:data");
1466 if (mValue == null) {
1467 mValue = field.addChild("value", "jabber:x:data");
1468 }
1469
1470 options.clear();
1471 List<Option> theOptions = Option.forField(field);
1472 options.addAll(theOptions);
1473
1474 float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1475 TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1476 float maxColumnWidth = theOptions.stream().map((x) ->
1477 StaticLayout.getDesiredWidth(x.toString(), paint)
1478 ).max(Float::compare).orElse(new Float(0.0));
1479 if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1480 binding.radios.setNumColumns(theOptions.size());
1481 } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1482 binding.radios.setNumColumns(theOptions.size() / 2);
1483 } else {
1484 binding.radios.setNumColumns(1);
1485 }
1486 binding.radios.setAdapter(options);
1487 }
1488
1489 @Override
1490 public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1491 if (mValue == null) return;
1492
1493 if (isChecked) mValue.setContent(options.getItem(radio.getId()).getValue());
1494 options.notifyDataSetChanged();
1495 }
1496 }
1497
1498 class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1499 public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1500 super(binding);
1501 binding.spinner.setOnItemSelectedListener(this);
1502 }
1503 protected Element mValue = null;
1504
1505 @Override
1506 public void bind(Element field) {
1507 String label = field.getAttribute("label");
1508 if (label == null) label = field.getAttribute("var");
1509 if (label == null) {
1510 binding.label.setVisibility(View.GONE);
1511 } else {
1512 binding.label.setVisibility(View.VISIBLE);
1513 binding.label.setText(label);
1514 binding.spinner.setPrompt(label);
1515 }
1516
1517 String desc = field.findChildContent("desc", "jabber:x:data");
1518 if (desc == null) {
1519 binding.desc.setVisibility(View.GONE);
1520 } else {
1521 binding.desc.setVisibility(View.VISIBLE);
1522 binding.desc.setText(desc);
1523 }
1524
1525 mValue = field.findChild("value", "jabber:x:data");
1526 if (mValue == null) {
1527 mValue = field.addChild("value", "jabber:x:data");
1528 }
1529
1530 ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1531 options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1532 options.addAll(Option.forField(field));
1533
1534 binding.spinner.setAdapter(options);
1535 binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1536 }
1537
1538 @Override
1539 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1540 Option o = (Option) parent.getItemAtPosition(pos);
1541 if (mValue == null) return;
1542
1543 mValue.setContent(o == null ? "" : o.getValue());
1544 }
1545
1546 @Override
1547 public void onNothingSelected(AdapterView<?> parent) {
1548 mValue.setContent("");
1549 }
1550 }
1551
1552 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1553 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1554 super(binding);
1555 binding.textinput.addTextChangedListener(this);
1556 }
1557 protected Element mValue = null;
1558
1559 @Override
1560 public void bind(Element field) {
1561 String label = field.getAttribute("label");
1562 if (label == null) label = field.getAttribute("var");
1563 if (label == null) label = "";
1564 binding.textinputLayout.setHint(label);
1565
1566 String desc = field.findChildContent("desc", "jabber:x:data");
1567 if (desc == null) {
1568 binding.desc.setVisibility(View.GONE);
1569 } else {
1570 binding.desc.setVisibility(View.VISIBLE);
1571 binding.desc.setText(desc);
1572 }
1573
1574 mValue = field.findChild("value", "jabber:x:data");
1575 if (mValue == null) {
1576 mValue = field.addChild("value", "jabber:x:data");
1577 }
1578 binding.textinput.setText(mValue.getContent());
1579
1580 binding.textinput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1581 Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1582 if (validate == null) return;
1583 String datatype = validate.getAttribute("datatype");
1584
1585 if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1586 binding.textinput.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1587 }
1588
1589 if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1590 binding.textinput.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1591 }
1592
1593 if (datatype.equals("xs:date")) {
1594 binding.textinput.setInputType(InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1595 }
1596
1597 if (datatype.equals("xs:dateTime")) {
1598 binding.textinput.setInputType(InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1599 }
1600
1601 if (datatype.equals("xs:time")) {
1602 binding.textinput.setInputType(InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1603 }
1604
1605 if (datatype.equals("xs:anyURI")) {
1606 binding.textinput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1607 }
1608 }
1609
1610 @Override
1611 public void afterTextChanged(Editable s) {
1612 if (mValue == null) return;
1613
1614 mValue.setContent(s.toString());
1615 }
1616
1617 @Override
1618 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1619
1620 @Override
1621 public void onTextChanged(CharSequence s, int start, int count, int after) { }
1622 }
1623
1624 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1625 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1626
1627 @Override
1628 public void bind(Element oob) {
1629 binding.webview.getSettings().setJavaScriptEnabled(true);
1630 binding.webview.setWebViewClient(new WebViewClient() {
1631 @Override
1632 public void onPageFinished(WebView view, String url) {
1633 super.onPageFinished(view, url);
1634 mTitle = view.getTitle();
1635 ConversationPagerAdapter.this.notifyDataSetChanged();
1636 }
1637 });
1638 binding.webview.loadUrl(oob.findChildContent("url", "jabber:x:oob"));
1639 }
1640 }
1641
1642 final int TYPE_ERROR = 1;
1643 final int TYPE_NOTE = 2;
1644 final int TYPE_WEB = 3;
1645 final int TYPE_RESULT_FIELD = 4;
1646 final int TYPE_TEXT_FIELD = 5;
1647 final int TYPE_CHECKBOX_FIELD = 6;
1648 final int TYPE_SPINNER_FIELD = 7;
1649 final int TYPE_RADIO_EDIT_FIELD = 8;
1650
1651 protected String mTitle;
1652 protected CommandPageBinding mBinding = null;
1653 protected IqPacket response = null;
1654 protected Element responseElement = null;
1655 protected SparseArray<Integer> viewTypes = new SparseArray<>();
1656 protected XmppConnectionService xmppConnectionService;
1657 protected ArrayAdapter<String> actionsAdapter;
1658
1659 CommandSession(String title, XmppConnectionService xmppConnectionService) {
1660 mTitle = title;
1661 this.xmppConnectionService = xmppConnectionService;
1662 actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
1663 @Override
1664 public View getView(int position, View convertView, ViewGroup parent) {
1665 View v = super.getView(position, convertView, parent);
1666 TextView tv = (TextView) v.findViewById(android.R.id.text1);
1667 tv.setGravity(Gravity.CENTER);
1668 int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
1669 if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
1670 tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
1671 tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
1672 return v;
1673 }
1674 };
1675 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
1676 @Override
1677 public void onChanged() {
1678 if (mBinding == null) return;
1679
1680 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
1681 }
1682
1683 @Override
1684 public void onInvalidated() {}
1685 });
1686 }
1687
1688 public String getTitle() {
1689 return mTitle;
1690 }
1691
1692 public void updateWithResponse(IqPacket iq) {
1693 this.responseElement = null;
1694 this.response = iq;
1695 this.viewTypes.clear();
1696 this.actionsAdapter.clear();
1697
1698 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
1699 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
1700 for (Element el : command.getChildren()) {
1701 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
1702 for (Element action : el.getChildren()) {
1703 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
1704 if (action.getName().equals("execute")) continue;
1705
1706 actionsAdapter.add(action.getName());
1707 }
1708 }
1709 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
1710 String title = el.findChildContent("title", "jabber:x:data");
1711 if (title != null) {
1712 mTitle = title;
1713 ConversationPagerAdapter.this.notifyDataSetChanged();
1714 }
1715 this.responseElement = el;
1716 break;
1717 }
1718 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
1719 String url = el.findChildContent("url", "jabber:x:oob");
1720 if (url != null) {
1721 String scheme = Uri.parse(url).getScheme();
1722 if (scheme.equals("http") || scheme.equals("https")) {
1723 this.responseElement = el;
1724 break;
1725 }
1726 }
1727 }
1728 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
1729 this.responseElement = el;
1730 break;
1731 }
1732 }
1733
1734 if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
1735 removeSession(this);
1736 return;
1737 }
1738 }
1739
1740 if (actionsAdapter.getCount() > 0) {
1741 if (actionsAdapter.getPosition("cancel") < 0) actionsAdapter.insert("cancel", 0);
1742 } else {
1743 actionsAdapter.add("close");
1744 }
1745
1746 notifyDataSetChanged();
1747 }
1748
1749 @Override
1750 public int getItemCount() {
1751 if (response == null) return 0;
1752 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
1753 int i = 0;
1754 for (Element el : responseElement.getChildren()) {
1755 if (!el.getNamespace().equals("jabber:x:data")) continue;
1756 if (el.getName().equals("title")) continue;
1757 if (el.getName().equals("field")) {
1758 String type = el.getAttribute("type");
1759 if (type != null && type.equals("hidden")) continue;
1760 }
1761
1762 i++;
1763 }
1764 return i;
1765 }
1766 return 1;
1767 }
1768
1769 public Element getItem(int position) {
1770 if (response == null) return null;
1771
1772 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
1773 if (responseElement.getNamespace().equals("jabber:x:data")) {
1774 int i = 0;
1775 for (Element el : responseElement.getChildren()) {
1776 if (!el.getNamespace().equals("jabber:x:data")) continue;
1777 if (el.getName().equals("title")) continue;
1778 if (el.getName().equals("field")) {
1779 String type = el.getAttribute("type");
1780 if (type != null && type.equals("hidden")) continue;
1781 }
1782
1783 if (i < position) {
1784 i++;
1785 continue;
1786 }
1787
1788 return el;
1789 }
1790 }
1791 }
1792
1793 return responseElement == null ? response : responseElement;
1794 }
1795
1796 @Override
1797 public int getItemViewType(int position) {
1798 if (viewTypes.get(position) != null) return viewTypes.get(position);
1799 if (response == null) return -1;
1800
1801 if (response.getType() == IqPacket.TYPE.RESULT) {
1802 Element item = getItem(position);
1803 if (item.getName().equals("note")) {
1804 viewTypes.put(position, TYPE_NOTE);
1805 return TYPE_NOTE;
1806 }
1807 if (item.getNamespace().equals("jabber:x:oob")) {
1808 viewTypes.put(position, TYPE_WEB);
1809 return TYPE_WEB;
1810 }
1811 if (item.getName().equals("instructions") && item.getNamespace().equals("jabber:x:data")) {
1812 viewTypes.put(position, TYPE_NOTE);
1813 return TYPE_NOTE;
1814 }
1815 if (item.getName().equals("field") && item.getNamespace().equals("jabber:x:data")) {
1816 String formType = responseElement.getAttribute("type");
1817 if (formType == null) return -1;
1818
1819 String fieldType = item.getAttribute("type");
1820 if (fieldType == null) fieldType = "text-single";
1821
1822 if (formType.equals("result") || fieldType.equals("fixed")) {
1823 viewTypes.put(position, TYPE_RESULT_FIELD);
1824 return TYPE_RESULT_FIELD;
1825 }
1826 if (formType.equals("form")) {
1827 viewTypes.put(position, TYPE_CHECKBOX_FIELD);
1828 if (fieldType.equals("boolean")) {
1829 return TYPE_CHECKBOX_FIELD;
1830 }
1831 if (fieldType.equals("list-single")) {
1832 if (item.findChild("value", "jabber:x:data") == null) {
1833 viewTypes.put(position, TYPE_RADIO_EDIT_FIELD);
1834 return TYPE_RADIO_EDIT_FIELD;
1835 }
1836
1837 viewTypes.put(position, TYPE_SPINNER_FIELD);
1838 return TYPE_SPINNER_FIELD;
1839 }
1840
1841 viewTypes.put(position, TYPE_TEXT_FIELD);
1842 return TYPE_TEXT_FIELD;
1843 }
1844 }
1845 return -1;
1846 } else {
1847 return TYPE_ERROR;
1848 }
1849 }
1850
1851 @Override
1852 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
1853 switch(viewType) {
1854 case TYPE_ERROR: {
1855 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1856 return new ErrorViewHolder(binding);
1857 }
1858 case TYPE_NOTE: {
1859 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1860 return new NoteViewHolder(binding);
1861 }
1862 case TYPE_WEB: {
1863 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
1864 return new WebViewHolder(binding);
1865 }
1866 case TYPE_RESULT_FIELD: {
1867 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
1868 return new ResultFieldViewHolder(binding);
1869 }
1870 case TYPE_CHECKBOX_FIELD: {
1871 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
1872 return new CheckboxFieldViewHolder(binding);
1873 }
1874 case TYPE_RADIO_EDIT_FIELD: {
1875 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
1876 return new RadioEditFieldViewHolder(binding);
1877 }
1878 case TYPE_SPINNER_FIELD: {
1879 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
1880 return new SpinnerFieldViewHolder(binding);
1881 }
1882 case TYPE_TEXT_FIELD: {
1883 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
1884 return new TextFieldViewHolder(binding);
1885 }
1886 default:
1887 throw new IllegalArgumentException("Unknown viewType: " + viewType);
1888 }
1889 }
1890
1891 @Override
1892 public void onBindViewHolder(ViewHolder viewHolder, int position) {
1893 viewHolder.bind(getItem(position));
1894 }
1895
1896 public View getView() {
1897 return mBinding.getRoot();
1898 }
1899
1900 public boolean execute() {
1901 return execute("execute");
1902 }
1903
1904 public boolean execute(int actionPosition) {
1905 return execute(actionsAdapter.getItem(actionPosition));
1906 }
1907
1908 public boolean execute(String action) {
1909 if (response == null || responseElement == null) return true;
1910 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
1911 if (command == null) return true;
1912 String status = command.getAttribute("status");
1913 if (status == null || !status.equals("executing")) return true;
1914 if (!responseElement.getName().equals("x") || !responseElement.getNamespace().equals("jabber:x:data")) return true;
1915 String formType = responseElement.getAttribute("type");
1916 if (formType == null || !formType.equals("form")) return true;
1917
1918 responseElement.setAttribute("type", "submit");
1919
1920 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1921 packet.setTo(response.getFrom());
1922 final Element c = packet.addChild("command", Namespace.COMMANDS);
1923 c.setAttribute("node", command.getAttribute("node"));
1924 c.setAttribute("sessionid", command.getAttribute("sessionid"));
1925 c.setAttribute("action", action);
1926 c.addChild(responseElement);
1927
1928 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1929 getView().post(() -> {
1930 updateWithResponse(iq);
1931 });
1932 });
1933
1934 return false;
1935 }
1936
1937 public void setBinding(CommandPageBinding b) {
1938 mBinding = b;
1939 mBinding.form.setLayoutManager(new LinearLayoutManager(mPager.getContext()) {
1940 @Override
1941 public boolean canScrollVertically() { return getItemCount() > 1; }
1942 });
1943 mBinding.form.setAdapter(this);
1944 mBinding.actions.setAdapter(actionsAdapter);
1945 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
1946 if (execute(pos)) {
1947 removeSession(CommandSession.this);
1948 }
1949 });
1950
1951 actionsAdapter.notifyDataSetChanged();
1952 }
1953 }
1954 }
1955}