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