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