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