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