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.getType().equals(Optional.of("boolean")) ? new ArrayList<>(List.of(new Option("false", binding.getRoot().getContext().getString(R.string.no)), new Option("true", binding.getRoot().getContext().getString(R.string.yes)))) : 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 && !mValue.getContent().equals("")) {
1950 // Synthesize default option for custom value
1951 defaultOption = new Option(mValue.getContent(), mValue.getContent());
1952 }
1953 if (defaultOption == null) {
1954 binding.defaultButton.setVisibility(View.GONE);
1955 } else {
1956 theOptions.remove(defaultOption);
1957 binding.defaultButton.setVisibility(View.VISIBLE);
1958
1959 final SVG defaultIcon = defaultOption.getIcon();
1960 if (defaultIcon != null) {
1961 defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1962 DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
1963 Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
1964 bitmap.setDensity(display.densityDpi);
1965 Canvas bmcanvas = new Canvas(bitmap);
1966 defaultIcon.renderToCanvas(bmcanvas);
1967 binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
1968 }
1969
1970 binding.defaultButton.setText(defaultOption.toString());
1971 binding.defaultButton.setOnClickListener((view) -> {
1972 loading = true;
1973 mValue.setContent(defaultOption.getValue());
1974 execute();
1975 });
1976 }
1977
1978 options.addAll(theOptions);
1979 binding.buttons.setAdapter(options);
1980 }
1981 }
1982
1983 class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1984 public TextFieldViewHolder(CommandTextFieldBinding binding) {
1985 super(binding);
1986 binding.textinput.addTextChangedListener(this);
1987 }
1988 protected Element mValue = null;
1989
1990 @Override
1991 public void bind(Item item) {
1992 Field field = (Field) item;
1993 binding.textinputLayout.setHint(field.getLabel().or(""));
1994
1995 binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1996 for (String desc : field.getDesc().asSet()) {
1997 binding.textinputLayout.setHelperText(desc);
1998 }
1999
2000 binding.textinputLayout.setErrorEnabled(field.error != null);
2001 if (field.error != null) binding.textinputLayout.setError(field.error);
2002
2003 mValue = field.getValue();
2004 binding.textinput.setText(mValue.getContent());
2005 setupInputType(field.el, binding.textinput, binding.textinputLayout);
2006 }
2007
2008 @Override
2009 public void afterTextChanged(Editable s) {
2010 if (mValue == null) return;
2011
2012 mValue.setContent(s.toString());
2013 }
2014
2015 @Override
2016 public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2017
2018 @Override
2019 public void onTextChanged(CharSequence s, int start, int count, int after) { }
2020 }
2021
2022 class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2023 public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2024 protected String boundUrl = "";
2025
2026 @Override
2027 public void bind(Item oob) {
2028 setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2029 binding.webview.getSettings().setJavaScriptEnabled(true);
2030 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");
2031 binding.webview.getSettings().setDatabaseEnabled(true);
2032 binding.webview.getSettings().setDomStorageEnabled(true);
2033 binding.webview.setWebChromeClient(new WebChromeClient() {
2034 @Override
2035 public void onProgressChanged(WebView view, int newProgress) {
2036 binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2037 binding.progressbar.setProgress(newProgress);
2038 }
2039 });
2040 binding.webview.setWebViewClient(new WebViewClient() {
2041 @Override
2042 public void onPageFinished(WebView view, String url) {
2043 super.onPageFinished(view, url);
2044 mTitle = view.getTitle();
2045 ConversationPagerAdapter.this.notifyDataSetChanged();
2046 }
2047 });
2048 final String url = oob.el.findChildContent("url", "jabber:x:oob");
2049 if (!boundUrl.equals(url)) {
2050 binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2051 binding.webview.loadUrl(url);
2052 boundUrl = url;
2053 }
2054 }
2055
2056 class JsObject {
2057 @JavascriptInterface
2058 public void execute() { execute("execute"); }
2059
2060 @JavascriptInterface
2061 public void execute(String action) {
2062 getView().post(() -> {
2063 actionToWebview = null;
2064 if(CommandSession.this.execute(action)) {
2065 removeSession(CommandSession.this);
2066 }
2067 });
2068 }
2069
2070 @JavascriptInterface
2071 public void preventDefault() {
2072 actionToWebview = binding.webview;
2073 }
2074 }
2075 }
2076
2077 class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2078 public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2079
2080 @Override
2081 public void bind(Item item) { }
2082 }
2083
2084 class Item {
2085 protected Element el;
2086 protected int viewType;
2087 protected String error = null;
2088
2089 Item(Element el, int viewType) {
2090 this.el = el;
2091 this.viewType = viewType;
2092 }
2093
2094 public boolean validate() {
2095 error = null;
2096 return true;
2097 }
2098 }
2099
2100 class Field extends Item {
2101 Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2102
2103 @Override
2104 public boolean validate() {
2105 if (!super.validate()) return false;
2106 if (el.findChild("required", "jabber:x:data") == null) return true;
2107 if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2108
2109 error = "this value is required";
2110 return false;
2111 }
2112
2113 public String getVar() {
2114 return el.getAttribute("var");
2115 }
2116
2117 public Optional<String> getType() {
2118 return Optional.fromNullable(el.getAttribute("type"));
2119 }
2120
2121 public Optional<String> getLabel() {
2122 String label = el.getAttribute("label");
2123 if (label == null) label = getVar();
2124 return Optional.fromNullable(label);
2125 }
2126
2127 public Optional<String> getDesc() {
2128 return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2129 }
2130
2131 public Element getValue() {
2132 Element value = el.findChild("value", "jabber:x:data");
2133 if (value == null) {
2134 value = el.addChild("value", "jabber:x:data");
2135 }
2136 return value;
2137 }
2138
2139 public List<Option> getOptions() {
2140 return Option.forField(el);
2141 }
2142 }
2143
2144 class Cell extends Item {
2145 protected Field reported;
2146
2147 Cell(Field reported, Element item) {
2148 super(item, TYPE_RESULT_CELL);
2149 this.reported = reported;
2150 }
2151 }
2152
2153 protected Field mkField(Element el) {
2154 int viewType = -1;
2155
2156 String formType = responseElement.getAttribute("type");
2157 if (formType != null) {
2158 String fieldType = el.getAttribute("type");
2159 if (fieldType == null) fieldType = "text-single";
2160
2161 if (formType.equals("result") || fieldType.equals("fixed")) {
2162 viewType = TYPE_RESULT_FIELD;
2163 } else if (formType.equals("form")) {
2164 if (fieldType.equals("boolean")) {
2165 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2166 viewType = TYPE_BUTTON_GRID_FIELD;
2167 } else {
2168 viewType = TYPE_CHECKBOX_FIELD;
2169 }
2170 } else if (fieldType.equals("list-single")) {
2171 Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2172 if (Option.forField(el).size() > 9) {
2173 viewType = TYPE_SEARCH_LIST_FIELD;
2174 } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2175 viewType = TYPE_BUTTON_GRID_FIELD;
2176 } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2177 viewType = TYPE_RADIO_EDIT_FIELD;
2178 } else {
2179 viewType = TYPE_SPINNER_FIELD;
2180 }
2181 } else {
2182 viewType = TYPE_TEXT_FIELD;
2183 }
2184 }
2185
2186 return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2187 }
2188
2189 return null;
2190 }
2191
2192 protected Item mkItem(Element el, int pos) {
2193 int viewType = -1;
2194
2195 if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2196 if (el.getName().equals("note")) {
2197 viewType = TYPE_NOTE;
2198 } else if (el.getNamespace().equals("jabber:x:oob")) {
2199 viewType = TYPE_WEB;
2200 } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2201 viewType = TYPE_NOTE;
2202 } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2203 Field field = mkField(el);
2204 if (field != null) {
2205 items.put(pos, field);
2206 return field;
2207 }
2208 }
2209 } else if (response != null) {
2210 viewType = TYPE_ERROR;
2211 }
2212
2213 Item item = new Item(el, viewType);
2214 items.put(pos, item);
2215 return item;
2216 }
2217
2218 class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2219 protected Context ctx;
2220
2221 public ActionsAdapter(Context ctx) {
2222 super(ctx, R.layout.simple_list_item);
2223 this.ctx = ctx;
2224 }
2225
2226 @Override
2227 public View getView(int position, View convertView, ViewGroup parent) {
2228 View v = super.getView(position, convertView, parent);
2229 TextView tv = (TextView) v.findViewById(android.R.id.text1);
2230 tv.setGravity(Gravity.CENTER);
2231 tv.setText(getItem(position).second);
2232 int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2233 if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2234 tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2235 tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2236 return v;
2237 }
2238
2239 public int getPosition(String s) {
2240 for(int i = 0; i < getCount(); i++) {
2241 if (getItem(i).first.equals(s)) return i;
2242 }
2243 return -1;
2244 }
2245
2246 public int countExceptCancel() {
2247 int count = 0;
2248 for(int i = 0; i < getCount(); i++) {
2249 if (!getItem(i).first.equals("cancel")) count++;
2250 }
2251 return count;
2252 }
2253
2254 public void clearExceptCancel() {
2255 Pair<String,String> cancelItem = null;
2256 for(int i = 0; i < getCount(); i++) {
2257 if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2258 }
2259 clear();
2260 if (cancelItem != null) add(cancelItem);
2261 }
2262 }
2263
2264 final int TYPE_ERROR = 1;
2265 final int TYPE_NOTE = 2;
2266 final int TYPE_WEB = 3;
2267 final int TYPE_RESULT_FIELD = 4;
2268 final int TYPE_TEXT_FIELD = 5;
2269 final int TYPE_CHECKBOX_FIELD = 6;
2270 final int TYPE_SPINNER_FIELD = 7;
2271 final int TYPE_RADIO_EDIT_FIELD = 8;
2272 final int TYPE_RESULT_CELL = 9;
2273 final int TYPE_PROGRESSBAR = 10;
2274 final int TYPE_SEARCH_LIST_FIELD = 11;
2275 final int TYPE_ITEM_CARD = 12;
2276 final int TYPE_BUTTON_GRID_FIELD = 13;
2277
2278 protected boolean loading = false;
2279 protected Timer loadingTimer = new Timer();
2280 protected String mTitle;
2281 protected String mNode;
2282 protected CommandPageBinding mBinding = null;
2283 protected IqPacket response = null;
2284 protected Element responseElement = null;
2285 protected List<Field> reported = null;
2286 protected SparseArray<Item> items = new SparseArray<>();
2287 protected XmppConnectionService xmppConnectionService;
2288 protected ActionsAdapter actionsAdapter;
2289 protected GridLayoutManager layoutManager;
2290 protected WebView actionToWebview = null;
2291 protected int fillableFieldCount = 0;
2292
2293 CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2294 loading();
2295 mTitle = title;
2296 mNode = node;
2297 this.xmppConnectionService = xmppConnectionService;
2298 if (mPager != null) setupLayoutManager();
2299 actionsAdapter = new ActionsAdapter(xmppConnectionService);
2300 actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2301 @Override
2302 public void onChanged() {
2303 if (mBinding == null) return;
2304
2305 mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2306 }
2307
2308 @Override
2309 public void onInvalidated() {}
2310 });
2311 }
2312
2313 public String getTitle() {
2314 return mTitle;
2315 }
2316
2317 public void updateWithResponse(IqPacket iq) {
2318 this.loadingTimer.cancel();
2319 this.loadingTimer = new Timer();
2320 this.loading = false;
2321 this.responseElement = null;
2322 this.fillableFieldCount = 0;
2323 this.reported = null;
2324 this.response = iq;
2325 this.items.clear();
2326 this.actionsAdapter.clear();
2327 layoutManager.setSpanCount(1);
2328
2329 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2330 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2331 if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2332 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2333 }
2334
2335 for (Element el : command.getChildren()) {
2336 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2337 for (Element action : el.getChildren()) {
2338 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2339 if (action.getName().equals("execute")) continue;
2340
2341 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2342 }
2343 }
2344 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2345 Data form = Data.parse(el);
2346 String title = form.getTitle();
2347 if (title != null) {
2348 mTitle = title;
2349 ConversationPagerAdapter.this.notifyDataSetChanged();
2350 }
2351
2352 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2353 this.responseElement = el;
2354 setupReported(el.findChild("reported", "jabber:x:data"));
2355 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2356 }
2357
2358 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2359 if (actionList != null) {
2360 actionsAdapter.clear();
2361
2362 for (Option action : actionList.getOptions()) {
2363 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2364 }
2365 }
2366
2367 String fillableFieldType = null;
2368 String fillableFieldValue = null;
2369 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2370 if (field.getType() != null && !field.getType().equals("hidden") && !field.getType().equals("fixed") && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2371 fillableFieldType = field.getType();
2372 fillableFieldValue = field.getValue();
2373 fillableFieldCount++;
2374 }
2375 }
2376
2377 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2378 actionsAdapter.clearExceptCancel();
2379 }
2380 break;
2381 }
2382 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2383 String url = el.findChildContent("url", "jabber:x:oob");
2384 if (url != null) {
2385 String scheme = Uri.parse(url).getScheme();
2386 if (scheme.equals("http") || scheme.equals("https")) {
2387 this.responseElement = el;
2388 break;
2389 }
2390 }
2391 }
2392 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2393 this.responseElement = el;
2394 break;
2395 }
2396 }
2397
2398 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2399 removeSession(this);
2400 return;
2401 }
2402
2403 if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && fillableFieldCount > 1) {
2404 // No actions have been given, but we are not done?
2405 // This is probably a spec violation, but we should do *something*
2406 actionsAdapter.add(Pair.create("execute", "execute"));
2407 }
2408
2409 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2410 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2411 actionsAdapter.add(Pair.create("close", "close"));
2412 } else if (actionsAdapter.getPosition("cancel") < 0) {
2413 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2414 }
2415 }
2416 }
2417
2418 if (actionsAdapter.isEmpty()) {
2419 actionsAdapter.add(Pair.create("close", "close"));
2420 }
2421
2422 notifyDataSetChanged();
2423 }
2424
2425 protected void setupReported(Element el) {
2426 if (el == null) {
2427 reported = null;
2428 return;
2429 }
2430
2431 reported = new ArrayList<>();
2432 for (Element fieldEl : el.getChildren()) {
2433 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2434 reported.add(mkField(fieldEl));
2435 }
2436 }
2437
2438 @Override
2439 public int getItemCount() {
2440 if (loading) return 1;
2441 if (response == null) return 0;
2442 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2443 int i = 0;
2444 for (Element el : responseElement.getChildren()) {
2445 if (!el.getNamespace().equals("jabber:x:data")) continue;
2446 if (el.getName().equals("title")) continue;
2447 if (el.getName().equals("field")) {
2448 String type = el.getAttribute("type");
2449 if (type != null && type.equals("hidden")) continue;
2450 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2451 }
2452
2453 if (el.getName().equals("reported") || el.getName().equals("item")) {
2454 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2455 if (el.getName().equals("reported")) continue;
2456 i += 1;
2457 } else {
2458 if (reported != null) i += reported.size();
2459 }
2460 continue;
2461 }
2462
2463 i++;
2464 }
2465 return i;
2466 }
2467 return 1;
2468 }
2469
2470 public Item getItem(int position) {
2471 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2472 if (items.get(position) != null) return items.get(position);
2473 if (response == null) return null;
2474
2475 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2476 if (responseElement.getNamespace().equals("jabber:x:data")) {
2477 int i = 0;
2478 for (Element el : responseElement.getChildren()) {
2479 if (!el.getNamespace().equals("jabber:x:data")) continue;
2480 if (el.getName().equals("title")) continue;
2481 if (el.getName().equals("field")) {
2482 String type = el.getAttribute("type");
2483 if (type != null && type.equals("hidden")) continue;
2484 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2485 }
2486
2487 if (el.getName().equals("reported") || el.getName().equals("item")) {
2488 Cell cell = null;
2489
2490 if (reported != null) {
2491 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2492 if (el.getName().equals("reported")) continue;
2493 if (i == position) {
2494 items.put(position, new Item(el, TYPE_ITEM_CARD));
2495 return items.get(position);
2496 }
2497 } else {
2498 if (reported.size() > position - i) {
2499 Field reportedField = reported.get(position - i);
2500 Element itemField = null;
2501 if (el.getName().equals("item")) {
2502 for (Element subel : el.getChildren()) {
2503 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2504 itemField = subel;
2505 break;
2506 }
2507 }
2508 }
2509 cell = new Cell(reportedField, itemField);
2510 } else {
2511 i += reported.size();
2512 continue;
2513 }
2514 }
2515 }
2516
2517 if (cell != null) {
2518 items.put(position, cell);
2519 return cell;
2520 }
2521 }
2522
2523 if (i < position) {
2524 i++;
2525 continue;
2526 }
2527
2528 return mkItem(el, position);
2529 }
2530 }
2531 }
2532
2533 return mkItem(responseElement == null ? response : responseElement, position);
2534 }
2535
2536 @Override
2537 public int getItemViewType(int position) {
2538 return getItem(position).viewType;
2539 }
2540
2541 @Override
2542 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2543 switch(viewType) {
2544 case TYPE_ERROR: {
2545 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2546 return new ErrorViewHolder(binding);
2547 }
2548 case TYPE_NOTE: {
2549 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2550 return new NoteViewHolder(binding);
2551 }
2552 case TYPE_WEB: {
2553 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2554 return new WebViewHolder(binding);
2555 }
2556 case TYPE_RESULT_FIELD: {
2557 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2558 return new ResultFieldViewHolder(binding);
2559 }
2560 case TYPE_RESULT_CELL: {
2561 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2562 return new ResultCellViewHolder(binding);
2563 }
2564 case TYPE_ITEM_CARD: {
2565 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2566 return new ItemCardViewHolder(binding);
2567 }
2568 case TYPE_CHECKBOX_FIELD: {
2569 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2570 return new CheckboxFieldViewHolder(binding);
2571 }
2572 case TYPE_SEARCH_LIST_FIELD: {
2573 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2574 return new SearchListFieldViewHolder(binding);
2575 }
2576 case TYPE_RADIO_EDIT_FIELD: {
2577 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2578 return new RadioEditFieldViewHolder(binding);
2579 }
2580 case TYPE_SPINNER_FIELD: {
2581 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2582 return new SpinnerFieldViewHolder(binding);
2583 }
2584 case TYPE_BUTTON_GRID_FIELD: {
2585 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2586 return new ButtonGridFieldViewHolder(binding);
2587 }
2588 case TYPE_TEXT_FIELD: {
2589 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2590 return new TextFieldViewHolder(binding);
2591 }
2592 case TYPE_PROGRESSBAR: {
2593 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2594 return new ProgressBarViewHolder(binding);
2595 }
2596 default:
2597 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2598 }
2599 }
2600
2601 @Override
2602 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2603 viewHolder.bind(getItem(position));
2604 }
2605
2606 public View getView() {
2607 return mBinding.getRoot();
2608 }
2609
2610 public boolean validate() {
2611 int count = getItemCount();
2612 boolean isValid = true;
2613 for (int i = 0; i < count; i++) {
2614 boolean oneIsValid = getItem(i).validate();
2615 isValid = isValid && oneIsValid;
2616 }
2617 notifyDataSetChanged();
2618 return isValid;
2619 }
2620
2621 public boolean execute() {
2622 return execute("execute");
2623 }
2624
2625 public boolean execute(int actionPosition) {
2626 return execute(actionsAdapter.getItem(actionPosition).first);
2627 }
2628
2629 public boolean execute(String action) {
2630 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2631
2632 if (response == null) return true;
2633 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2634 if (command == null) return true;
2635 String status = command.getAttribute("status");
2636 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2637
2638 if (actionToWebview != null) {
2639 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2640 return false;
2641 }
2642
2643 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2644 packet.setTo(response.getFrom());
2645 final Element c = packet.addChild("command", Namespace.COMMANDS);
2646 c.setAttribute("node", mNode);
2647 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2648
2649 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2650 if (!action.equals("cancel") &&
2651 !action.equals("prev") &&
2652 responseElement != null &&
2653 responseElement.getName().equals("x") &&
2654 responseElement.getNamespace().equals("jabber:x:data") &&
2655 formType != null && formType.equals("form")) {
2656
2657 Data form = Data.parse(responseElement);
2658 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2659 if (actionList != null) {
2660 actionList.setValue(action);
2661 c.setAttribute("action", "execute");
2662 }
2663
2664 responseElement.setAttribute("type", "submit");
2665 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2666 if (rsm != null) {
2667 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2668 max.setContent("1000");
2669 rsm.addChild(max);
2670 }
2671
2672 c.addChild(responseElement);
2673 }
2674
2675 if (c.getAttribute("action") == null) c.setAttribute("action", action);
2676
2677 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2678 getView().post(() -> {
2679 updateWithResponse(iq);
2680 });
2681 });
2682
2683 loading();
2684 return false;
2685 }
2686
2687 protected void loading() {
2688 loadingTimer.schedule(new TimerTask() {
2689 @Override
2690 public void run() {
2691 getView().post(() -> {
2692 loading = true;
2693 notifyDataSetChanged();
2694 });
2695 }
2696 }, 500);
2697 }
2698
2699 protected GridLayoutManager setupLayoutManager() {
2700 int spanCount = 1;
2701
2702 if (reported != null && mPager != null) {
2703 float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2704 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2705 float tableHeaderWidth = reported.stream().reduce(
2706 0f,
2707 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2708 (a, b) -> a + b
2709 );
2710
2711 spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2712 }
2713
2714 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2715 items.clear();
2716 notifyDataSetChanged();
2717 }
2718
2719 layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2720 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2721 @Override
2722 public int getSpanSize(int position) {
2723 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2724 return 1;
2725 }
2726 });
2727 return layoutManager;
2728 }
2729
2730 public void setBinding(CommandPageBinding b) {
2731 mBinding = b;
2732 // https://stackoverflow.com/a/32350474/8611
2733 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2734 @Override
2735 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2736 if(rv.getChildCount() > 0) {
2737 int[] location = new int[2];
2738 rv.getLocationOnScreen(location);
2739 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2740 if (childView instanceof ViewGroup) {
2741 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2742 }
2743 if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2744 int action = e.getAction();
2745 switch (action) {
2746 case MotionEvent.ACTION_DOWN:
2747 rv.requestDisallowInterceptTouchEvent(true);
2748 }
2749 }
2750 }
2751
2752 return false;
2753 }
2754
2755 @Override
2756 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2757
2758 @Override
2759 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2760 });
2761 mBinding.form.setLayoutManager(setupLayoutManager());
2762 mBinding.form.setAdapter(this);
2763 mBinding.actions.setAdapter(actionsAdapter);
2764 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2765 if (execute(pos)) {
2766 removeSession(CommandSession.this);
2767 }
2768 });
2769
2770 actionsAdapter.notifyDataSetChanged();
2771 }
2772
2773 // https://stackoverflow.com/a/36037991/8611
2774 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2775 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2776 View child = viewGroup.getChildAt(i);
2777 if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2778 View foundView = findViewAt((ViewGroup) child, x, y);
2779 if (foundView != null && foundView.isShown()) {
2780 return foundView;
2781 }
2782 } else {
2783 int[] location = new int[2];
2784 child.getLocationOnScreen(location);
2785 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2786 if (rect.contains((int)x, (int)y)) {
2787 return child;
2788 }
2789 }
2790 }
2791
2792 return null;
2793 }
2794 }
2795 }
2796}