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 boolean actionsCleared = false;
2333 Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2334 if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2335 if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2336 xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2337 }
2338
2339 for (Element el : command.getChildren()) {
2340 if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2341 for (Element action : el.getChildren()) {
2342 if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2343 if (action.getName().equals("execute")) continue;
2344
2345 actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2346 }
2347 }
2348 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2349 Data form = Data.parse(el);
2350 String title = form.getTitle();
2351 if (title != null) {
2352 mTitle = title;
2353 ConversationPagerAdapter.this.notifyDataSetChanged();
2354 }
2355
2356 if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2357 this.responseElement = el;
2358 setupReported(el.findChild("reported", "jabber:x:data"));
2359 if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2360 }
2361
2362 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2363 if (actionList != null) {
2364 actionsAdapter.clear();
2365
2366 for (Option action : actionList.getOptions()) {
2367 actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2368 }
2369 }
2370
2371 String fillableFieldType = null;
2372 String fillableFieldValue = null;
2373 for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2374 if (field.getType() != null && !field.getType().equals("hidden") && !field.getType().equals("fixed") && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2375 fillableFieldType = field.getType();
2376 fillableFieldValue = field.getValue();
2377 fillableFieldCount++;
2378 }
2379 }
2380
2381 if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2382 actionsCleared = true;
2383 actionsAdapter.clearExceptCancel();
2384 }
2385 break;
2386 }
2387 if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2388 String url = el.findChildContent("url", "jabber:x:oob");
2389 if (url != null) {
2390 String scheme = Uri.parse(url).getScheme();
2391 if (scheme.equals("http") || scheme.equals("https")) {
2392 this.responseElement = el;
2393 break;
2394 }
2395 }
2396 }
2397 if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2398 this.responseElement = el;
2399 break;
2400 }
2401 }
2402
2403 if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2404 removeSession(this);
2405 return;
2406 }
2407
2408 if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2409 // No actions have been given, but we are not done?
2410 // This is probably a spec violation, but we should do *something*
2411 actionsAdapter.add(Pair.create("execute", "execute"));
2412 }
2413
2414 if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2415 if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2416 actionsAdapter.add(Pair.create("close", "close"));
2417 } else if (actionsAdapter.getPosition("cancel") < 0) {
2418 actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2419 }
2420 }
2421 }
2422
2423 if (actionsAdapter.isEmpty()) {
2424 actionsAdapter.add(Pair.create("close", "close"));
2425 }
2426
2427 notifyDataSetChanged();
2428 }
2429
2430 protected void setupReported(Element el) {
2431 if (el == null) {
2432 reported = null;
2433 return;
2434 }
2435
2436 reported = new ArrayList<>();
2437 for (Element fieldEl : el.getChildren()) {
2438 if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2439 reported.add(mkField(fieldEl));
2440 }
2441 }
2442
2443 @Override
2444 public int getItemCount() {
2445 if (loading) return 1;
2446 if (response == null) return 0;
2447 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2448 int i = 0;
2449 for (Element el : responseElement.getChildren()) {
2450 if (!el.getNamespace().equals("jabber:x:data")) continue;
2451 if (el.getName().equals("title")) continue;
2452 if (el.getName().equals("field")) {
2453 String type = el.getAttribute("type");
2454 if (type != null && type.equals("hidden")) continue;
2455 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2456 }
2457
2458 if (el.getName().equals("reported") || el.getName().equals("item")) {
2459 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2460 if (el.getName().equals("reported")) continue;
2461 i += 1;
2462 } else {
2463 if (reported != null) i += reported.size();
2464 }
2465 continue;
2466 }
2467
2468 i++;
2469 }
2470 return i;
2471 }
2472 return 1;
2473 }
2474
2475 public Item getItem(int position) {
2476 if (loading) return new Item(null, TYPE_PROGRESSBAR);
2477 if (items.get(position) != null) return items.get(position);
2478 if (response == null) return null;
2479
2480 if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2481 if (responseElement.getNamespace().equals("jabber:x:data")) {
2482 int i = 0;
2483 for (Element el : responseElement.getChildren()) {
2484 if (!el.getNamespace().equals("jabber:x:data")) continue;
2485 if (el.getName().equals("title")) continue;
2486 if (el.getName().equals("field")) {
2487 String type = el.getAttribute("type");
2488 if (type != null && type.equals("hidden")) continue;
2489 if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2490 }
2491
2492 if (el.getName().equals("reported") || el.getName().equals("item")) {
2493 Cell cell = null;
2494
2495 if (reported != null) {
2496 if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2497 if (el.getName().equals("reported")) continue;
2498 if (i == position) {
2499 items.put(position, new Item(el, TYPE_ITEM_CARD));
2500 return items.get(position);
2501 }
2502 } else {
2503 if (reported.size() > position - i) {
2504 Field reportedField = reported.get(position - i);
2505 Element itemField = null;
2506 if (el.getName().equals("item")) {
2507 for (Element subel : el.getChildren()) {
2508 if (subel.getAttribute("var").equals(reportedField.getVar())) {
2509 itemField = subel;
2510 break;
2511 }
2512 }
2513 }
2514 cell = new Cell(reportedField, itemField);
2515 } else {
2516 i += reported.size();
2517 continue;
2518 }
2519 }
2520 }
2521
2522 if (cell != null) {
2523 items.put(position, cell);
2524 return cell;
2525 }
2526 }
2527
2528 if (i < position) {
2529 i++;
2530 continue;
2531 }
2532
2533 return mkItem(el, position);
2534 }
2535 }
2536 }
2537
2538 return mkItem(responseElement == null ? response : responseElement, position);
2539 }
2540
2541 @Override
2542 public int getItemViewType(int position) {
2543 return getItem(position).viewType;
2544 }
2545
2546 @Override
2547 public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2548 switch(viewType) {
2549 case TYPE_ERROR: {
2550 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2551 return new ErrorViewHolder(binding);
2552 }
2553 case TYPE_NOTE: {
2554 CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2555 return new NoteViewHolder(binding);
2556 }
2557 case TYPE_WEB: {
2558 CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2559 return new WebViewHolder(binding);
2560 }
2561 case TYPE_RESULT_FIELD: {
2562 CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2563 return new ResultFieldViewHolder(binding);
2564 }
2565 case TYPE_RESULT_CELL: {
2566 CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2567 return new ResultCellViewHolder(binding);
2568 }
2569 case TYPE_ITEM_CARD: {
2570 CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2571 return new ItemCardViewHolder(binding);
2572 }
2573 case TYPE_CHECKBOX_FIELD: {
2574 CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2575 return new CheckboxFieldViewHolder(binding);
2576 }
2577 case TYPE_SEARCH_LIST_FIELD: {
2578 CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2579 return new SearchListFieldViewHolder(binding);
2580 }
2581 case TYPE_RADIO_EDIT_FIELD: {
2582 CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2583 return new RadioEditFieldViewHolder(binding);
2584 }
2585 case TYPE_SPINNER_FIELD: {
2586 CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2587 return new SpinnerFieldViewHolder(binding);
2588 }
2589 case TYPE_BUTTON_GRID_FIELD: {
2590 CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2591 return new ButtonGridFieldViewHolder(binding);
2592 }
2593 case TYPE_TEXT_FIELD: {
2594 CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2595 return new TextFieldViewHolder(binding);
2596 }
2597 case TYPE_PROGRESSBAR: {
2598 CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2599 return new ProgressBarViewHolder(binding);
2600 }
2601 default:
2602 throw new IllegalArgumentException("Unknown viewType: " + viewType);
2603 }
2604 }
2605
2606 @Override
2607 public void onBindViewHolder(ViewHolder viewHolder, int position) {
2608 viewHolder.bind(getItem(position));
2609 }
2610
2611 public View getView() {
2612 return mBinding.getRoot();
2613 }
2614
2615 public boolean validate() {
2616 int count = getItemCount();
2617 boolean isValid = true;
2618 for (int i = 0; i < count; i++) {
2619 boolean oneIsValid = getItem(i).validate();
2620 isValid = isValid && oneIsValid;
2621 }
2622 notifyDataSetChanged();
2623 return isValid;
2624 }
2625
2626 public boolean execute() {
2627 return execute("execute");
2628 }
2629
2630 public boolean execute(int actionPosition) {
2631 return execute(actionsAdapter.getItem(actionPosition).first);
2632 }
2633
2634 public boolean execute(String action) {
2635 if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2636
2637 if (response == null) return true;
2638 Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2639 if (command == null) return true;
2640 String status = command.getAttribute("status");
2641 if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2642
2643 if (actionToWebview != null) {
2644 actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2645 return false;
2646 }
2647
2648 final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2649 packet.setTo(response.getFrom());
2650 final Element c = packet.addChild("command", Namespace.COMMANDS);
2651 c.setAttribute("node", mNode);
2652 c.setAttribute("sessionid", command.getAttribute("sessionid"));
2653
2654 String formType = responseElement == null ? null : responseElement.getAttribute("type");
2655 if (!action.equals("cancel") &&
2656 !action.equals("prev") &&
2657 responseElement != null &&
2658 responseElement.getName().equals("x") &&
2659 responseElement.getNamespace().equals("jabber:x:data") &&
2660 formType != null && formType.equals("form")) {
2661
2662 Data form = Data.parse(responseElement);
2663 eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2664 if (actionList != null) {
2665 actionList.setValue(action);
2666 c.setAttribute("action", "execute");
2667 }
2668
2669 responseElement.setAttribute("type", "submit");
2670 Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2671 if (rsm != null) {
2672 Element max = new Element("max", "http://jabber.org/protocol/rsm");
2673 max.setContent("1000");
2674 rsm.addChild(max);
2675 }
2676
2677 c.addChild(responseElement);
2678 }
2679
2680 if (c.getAttribute("action") == null) c.setAttribute("action", action);
2681
2682 xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2683 getView().post(() -> {
2684 updateWithResponse(iq);
2685 });
2686 });
2687
2688 loading();
2689 return false;
2690 }
2691
2692 protected void loading() {
2693 loadingTimer.schedule(new TimerTask() {
2694 @Override
2695 public void run() {
2696 getView().post(() -> {
2697 loading = true;
2698 notifyDataSetChanged();
2699 });
2700 }
2701 }, 500);
2702 }
2703
2704 protected GridLayoutManager setupLayoutManager() {
2705 int spanCount = 1;
2706
2707 if (reported != null && mPager != null) {
2708 float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2709 TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2710 float tableHeaderWidth = reported.stream().reduce(
2711 0f,
2712 (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2713 (a, b) -> a + b
2714 );
2715
2716 spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2717 }
2718
2719 if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2720 items.clear();
2721 notifyDataSetChanged();
2722 }
2723
2724 layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2725 layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2726 @Override
2727 public int getSpanSize(int position) {
2728 if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2729 return 1;
2730 }
2731 });
2732 return layoutManager;
2733 }
2734
2735 public void setBinding(CommandPageBinding b) {
2736 mBinding = b;
2737 // https://stackoverflow.com/a/32350474/8611
2738 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2739 @Override
2740 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2741 if(rv.getChildCount() > 0) {
2742 int[] location = new int[2];
2743 rv.getLocationOnScreen(location);
2744 View childView = rv.findChildViewUnder(e.getX(), e.getY());
2745 if (childView instanceof ViewGroup) {
2746 childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2747 }
2748 int action = e.getAction();
2749 switch (action) {
2750 case MotionEvent.ACTION_DOWN:
2751 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
2752 rv.requestDisallowInterceptTouchEvent(true);
2753 }
2754 case MotionEvent.ACTION_UP:
2755 if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
2756 rv.requestDisallowInterceptTouchEvent(true);
2757 }
2758 }
2759 }
2760
2761 return false;
2762 }
2763
2764 @Override
2765 public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2766
2767 @Override
2768 public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2769 });
2770 mBinding.form.setLayoutManager(setupLayoutManager());
2771 mBinding.form.setAdapter(this);
2772 mBinding.actions.setAdapter(actionsAdapter);
2773 mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2774 if (execute(pos)) {
2775 removeSession(CommandSession.this);
2776 }
2777 });
2778
2779 actionsAdapter.notifyDataSetChanged();
2780 }
2781
2782 // https://stackoverflow.com/a/36037991/8611
2783 private View findViewAt(ViewGroup viewGroup, float x, float y) {
2784 for(int i = 0; i < viewGroup.getChildCount(); i++) {
2785 View child = viewGroup.getChildAt(i);
2786 if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
2787 View foundView = findViewAt((ViewGroup) child, x, y);
2788 if (foundView != null && foundView.isShown()) {
2789 return foundView;
2790 }
2791 } else {
2792 int[] location = new int[2];
2793 child.getLocationOnScreen(location);
2794 Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2795 if (rect.contains((int)x, (int)y)) {
2796 return child;
2797 }
2798 }
2799 }
2800
2801 return null;
2802 }
2803 }
2804 }
2805}